Python Training at VMWare Bangalore - Day 3

Aug 16-18, 2017
Anand Chitipothu & Vikrant Patil

These notes are available online at http://notes.pipal.in/2017/vmware-advpy

© Pipal Academy LLP

Day 1 | Day 2 | Day 3

Testing Python Programs

In [5]:
%%file sq1.py
def square(x):
    return x*x

def test():
    print(square(3))
    
if __name__ == "__main__":
    test()
Writing sq1.py
In [6]:
!python sq1.py
9
In [9]:
%%file sq2.py
def square(x):
    return x*x

def test():
    if square(3) == 9:
        print("PASSED")
    
if __name__ == "__main__":
    test()
Overwriting sq2.py
In [10]:
!python sq2.py
PASSED

Python has an assert statement for doing this.

In [18]:
%%file sq2.py
def square(x):
    return x*x

def test_square():
    assert square(0) == 0
    assert square(3) == 9
    assert square(-3) == 9
    
if __name__ == "__main__":
    test_square()
Overwriting sq2.py
In [19]:
!python sq2.py
In [20]:
!py.test sq2.py
============================= test session starts ==============================
platform darwin -- Python 3.5.2, pytest-3.0.2, py-1.4.31, pluggy-0.3.1
rootdir: /Users/anand/trainings/2017/vmware-advpy, inifile: 
collected 1 items 

sq2.py .

=========================== 1 passed in 0.01 seconds ===========================

You can install py.test using:

pip install pytest

In [53]:
%%file now.py

import datetime

def now():
    return datetime.datetime.now()

def weekday():
    t = now()
    return t.strftime("%A")

if __name__ == "__main__":
    print(weekday())
Overwriting now.py
In [54]:
!python now.py
Friday
In [57]:
%%file test_now.py
import now
import datetime

def test_weekday(monkeypatch):
    faketime = 2010, 1, 1
    def fakenow():
        return datetime.datetime(*faketime)
    monkeypatch.setattr(now, "now", fakenow)
    
    faketime = 2010, 1, 1    
    assert now.weekday() == "Friday"
    
    faketime = 2010, 1, 2   
    assert now.weekday() == "Saturday"    
Overwriting test_now.py
In [58]:
!py.test test_now.py
============================= test session starts ==============================
platform darwin -- Python 3.5.2, pytest-3.0.2, py-1.4.31, pluggy-0.3.1
rootdir: /Users/anand/trainings/2017/vmware-advpy, inifile: 
collected 1 items 

test_now.py .

=========================== 1 passed in 0.01 seconds ===========================

Classes

In [59]:
def wordcount(fileobj):
    """Counts the number of words in the given file.
    """
    return len(fileobj.read().split())

What happens if I pass a string as argument to wordcount?

In [60]:
wordcount("hello world")
---------------------------------------
AttributeErrorTraceback (most recent call last)
<ipython-input-60-a5a99ec5ff12> in <module>()
----> 1 wordcount("hello world")

<ipython-input-59-5f093cc8292e> in wordcount(fileobj)
      2     """Counts the number of words in the given file.
      3     """
----> 4     return len(fileobj.read().split())

AttributeError: 'str' object has no attribute 'read'
In [61]:
class FakeFile:
    def read(self):
        return "Hello world"
In [62]:
wordcount(FakeFile())
Out[62]:
2

Let us look at a simple class.

In [67]:
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        
    def getx(self):
        return self.x
In [68]:
p = Point(3, 4)
In [69]:
print(p.x, p.y)
3 4
In [70]:
isinstance(p, Point)
Out[70]:
True
In [71]:
p.getx()
Out[71]:
3

The above code is equivalant to:

In [72]:
Point.getx(p)
Out[72]:
3
In [73]:
p.__dict__
Out[73]:
{'x': 3, 'y': 4}
In [74]:
p.__class__
Out[74]:
__main__.Point
In [75]:
Point.__dict__
Out[75]:
mappingproxy({'__dict__': <attribute '__dict__' of 'Point' objects>,
              '__doc__': None,
              '__init__': <function __main__.Point.__init__>,
              '__module__': '__main__',
              '__weakref__': <attribute '__weakref__' of 'Point' objects>,
              'getx': <function __main__.Point.getx>})

Let us dig a bit deeper...

In [76]:
p = Point(3, 4)
In [77]:
p.x
Out[77]:
3
In [78]:
p.y
Out[78]:
4
In [79]:
p.z = 5
In [82]:
class Point2:
    pass

def init_point2(self, x, y):
    self.x = x
    self.y = y

p2 = Point2()
init_point2(p2, 3, 4)

print(p2.x, p2.y)
3 4
In [83]:
p2.xx
---------------------------------------
AttributeErrorTraceback (most recent call last)
<ipython-input-83-a23007e7adf1> in <module>()
----> 1 p2.xx

AttributeError: 'Point2' object has no attribute 'xx'

Q: How to stop adding new attributes objects of a class.

In [84]:
class Point(object):
    __slots__ = ["x", "y"]
    
In [85]:
p = Point()
In [86]:
p.x = 1
p.y = 2
In [87]:
p.z = 3
---------------------------------------
AttributeErrorTraceback (most recent call last)
<ipython-input-87-a288bf462759> in <module>()
----> 1 p.z = 3

AttributeError: 'Point' object has no attribute 'z'

Q: Does python has private methods or attributes?

No, but anything that starts with an underscore is considered private implementation detail.

In [89]:
class Point:
    x = 0
    y = 0
In [90]:
p1 = Point()
p2 = Point()
In [91]:
print(p1.x, p1.y)
print(p2.x, p2.y)
0 0
0 0
In [92]:
p1.x = 1
p1.y = 2
In [93]:
print(p1.x, p1.y)
print(p2.x, p2.y)
1 2
0 0
In [94]:
Point.x = 10
In [95]:
print(p1.x, p1.y)
print(p2.x, p2.y)
1 2
10 0

Example: time taken

In [99]:
import os
def count_lines(dirpath):
    total = 0
    for f in os.listdir(dirpath):
        if os.path.isfile(f):
            path = os.path.join(dirpath, f)
            lines = len(open(path).readlines())
            total += lines
    return total
In [100]:
count_lines(".")
Out[100]:
41180
In [101]:
count_lines(".")
Out[101]:
1051197
In [102]:
count_lines(".")
Out[102]:
11051197
In [103]:
import os
import time
def count_lines(dirpath):
    total = 0
    t0 = time.time()
    for f in os.listdir(dirpath):
        if os.path.isfile(f):
            path = os.path.join(dirpath, f)
            lines = len(open(path).readlines())
            total += lines
    t1 = time.time()
    print("time taken", t1-t0)
    return total
In [104]:
count_lines(".")
time taken 2.373183012008667
Out[104]:
11051267
In [120]:
%%file timer.py
import time

tstart = 0
tstop = 0

def start():
    global tstart 
    tstart = time.time()
    
def stop():
    global tstop
    tstop = time.time()    
    
def time_taken():
    return tstop-tstart
Overwriting timer.py
In [122]:
%%file a.py
import os
import timer

def count_lines(dirpath):
    total = 0
    timer.start()
    for f in os.listdir(dirpath):
        if os.path.isfile(f):
            path = os.path.join(dirpath, f)
            lines = len(open(path).readlines())
            total += lines
    timer.stop()
    print("time taken", timer.time_taken())
    return total

if __name__ == "__main__":
    print(count_lines("."))
Overwriting a.py
In [123]:
!python a.py
time taken 2.4569990634918213
11051641

Example: Bank Account

In [124]:
%%file bank0.py

balance = 0

def deposit(amount):
    global balance
    balance += amount

def withdraw(amount):
    global balance
    balance -= amount
    
def get_balance():
    return balance

def main():
    deposit(100)
    withdraw(40)
    print(get_balance())
    
    deposit(20)
    print(get_balance())

if __name__ == "__main__":
    main()
Writing bank0.py
In [125]:
!python bank0.py
60
80

How to have multiple bank accounts?

In [132]:
%%file bank1.py

def make_account():
    return {"balance": 0}

def deposit(account, amount):
    account["balance"] += amount

def withdraw(account, amount):
    account["balance"] -= amount

def get_balance(account):
    return account["balance"]

def main():
    a1 = make_account()
    a2 = make_account()
    
    deposit(a1, 100)
    deposit(a2, 50)
    withdraw(a1, 40)
    withdraw(a2, 30)
    
    print(get_balance(a1), get_balance(a2))

if __name__ == "__main__":
    main()
Overwriting bank1.py
In [133]:
!python bank1.py
60 20

Let's try to the same with classes.

In [136]:
%%file bank2.py

class BankAccount:
    def __init__(self):
        self.balance = 0

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount

    def get_balance(self):
        return self.balance

def main():
    a1 = BankAccount()
    a2 = BankAccount()
    
    a1.deposit(100)
    a2.deposit(50)
    a1.withdraw(40)
    a2.withdraw(30)
    
    print(a1.get_balance(), a2.get_balance())

if __name__ == "__main__":
    main()
Overwriting bank2.py
In [137]:
!python bank2.py
60 20

Q: Can you make a class immutable?

Yes.

In Python, you can control every operation that is done on an object.

In [138]:
x = 3
In [139]:
x + 2
Out[139]:
5
In [140]:
x.__add__(2)
Out[140]:
5
In [141]:
class UpperCase:
    def __getattr__(self, name):
        return name.upper()
In [142]:
x = UpperCase()
In [143]:
x.name
Out[143]:
'NAME'
In [144]:
class Sealed:
    def __setattr__(self, name, value):
        raise Exception("No chance!")
In [145]:
s = Sealed()
In [146]:
s.x = 1
---------------------------------------
ExceptionTraceback (most recent call last)
<ipython-input-146-67ebd05ea624> in <module>()
----> 1 s.x = 1

<ipython-input-144-c14756ee149e> in __setattr__(self, name, value)
      1 class Sealed:
      2     def __setattr__(self, name, value):
----> 3         raise Exception("No chance!")

Exception: No chance!

Problem: Write a class Timer to measure the time taken in a task. The class should have start and stop methods and it should be able to find time taken between them.

t = Timer()
t.start()
do_something()
t.stop()
print("Time taken:", t.time_taken())

Example: Text Formatting

In [147]:
%%file five.txt
one
two
three
four
five
Writing five.txt
In [148]:
class Formatter:
    def format_text(self, text):
        """Formats the given text.
        
        This implementation returns the same text,
        but sub classes can override this method to
        provide different way of formatting.
        """
        return text
    
    def format_file(self, filename):
        text = open(filename).read()
        return self.format_text(text)
In [149]:
class UpperCaseFormatter(Formatter):
    def format_text(self, text):
        return text.upper()
In [150]:
f = UpperCaseFormatter()
In [152]:
print(f.format_text("Hello"))
HELLO
In [153]:
print(f.format_file("five.txt"))
ONE
TWO
THREE
FOUR
FIVE

Let us extend the formatting functionality to support formatting individul lines.

In [154]:
class LineFormatter(Formatter):
    def format_text(self, text):
        lines = text.splitlines()
        lines = [self.format_line(line) for line in lines]
        return "\n".join(lines)
        
    def format_line(self, line):
        return line
In [155]:
class PrefixFormatter(LineFormatter):
    def __init__(self, prefix):
        self.prefix = prefix
        
    def format_line(self, line):
        return self.prefix + line
In [156]:
f = PrefixFormatter("[INFO] ")
In [159]:
print(f.format_line("hello"))
[INFO] hello
In [160]:
print(f.format_text("a\nb\nc"))
[INFO] a
[INFO] b
[INFO] c
In [161]:
print(f.format_file("five.txt"))
[INFO] one
[INFO] two
[INFO] three
[INFO] four
[INFO] five

properties

In [208]:
class Person(object):
    def __init__(self, firstname, lastname):
        self.firstname = firstname
        self.lastname = lastname

    @property
    def fullname(self):
        print("calling fullname ...")
        return self.firstname + " " + self.lastname
In [200]:
p = Person("Alice", "whoever")
In [201]:
p.firstname
Out[201]:
'Alice'
In [202]:
p.lastname
Out[202]:
'whoever'
In [203]:
p.fullname
calling fullname ...
Out[203]:
'Alice whoever'
In [204]:
p.lastname = "xyz"
In [205]:
p.fullname
calling fullname ...
Out[205]:
'Alice xyz'
In [209]:
class Person(object):
    def __init__(self, firstname, lastname, email):
        self.firstname = firstname
        self.lastname = lastname
        self.email = email

    @property
    def fullname(self):
        print("calling fullname ...")
        return self.firstname + " " + self.lastname
In [210]:
p = Person("Alice", "Bob", "alice@example.com")
In [211]:
p.email
Out[211]:
'alice@example.com'
In [212]:
p.email = "not-an-email?"
In [221]:
class Person(object):
    def __init__(self, firstname, lastname, email):
        self.firstname = firstname
        self.lastname = lastname
        self._email = email
        
    @property
    def email(self):
        return self._email

    @email.setter
    def email(self, value):
        if "@" not in value:
            raise ValueError("Invalid email: " + repr(value))
        self._email = value

    @property
    def fullname(self):
        print("calling fullname ...")
        return self.firstname + " " + self.lastname
In [222]:
p = Person("Alice", "Bob", "alice@example.com")
In [223]:
p.email
Out[223]:
'alice@example.com'
In [225]:
p.email = "foo@example.com"

What if I want to add a work_email field to the Person class?

Descriptors

Descriptors are special kind of objects.

In [233]:
class Zero(object):
    def __get__(self, obj, cls):
        if obj is None:
            return self
        print("Zero.__get__")
        return 0
    
    def __repr__(self):
        return "<Descriptor Zero>"
In [234]:
class Foo:
    x = Zero()
In [235]:
f = Foo()
In [236]:
f.x
Zero.__get__
Out[236]:
0

When an attribute of an object is called and it's value is a descriptor, then __get__ of that descriptor is called.

In [237]:
Foo.__dict__
Out[237]:
mappingproxy({'__dict__': <attribute '__dict__' of 'Foo' objects>,
              '__doc__': None,
              '__module__': '__main__',
              '__weakref__': <attribute '__weakref__' of 'Foo' objects>,
              'x': <Descriptor Zero>})
In [238]:
Foo.x
Out[238]:
<Descriptor Zero>
In [239]:
Foo.x.__get__(f, Foo)
Zero.__get__
Out[239]:
0
In [245]:
class Email(object):
    def __get__(self, obj, cls):
        if obj is None:
            return self
        print("__get__")
        return obj.__dict__[self]

    def __set__(self, obj, value):
        print("__set__", value)
        if "@" not in value:
            raise ValueError("Not a valid e-mail: " + repr(email))
        obj.__dict__[self] = value
    
In [249]:
class Person(object):
    email = Email()
    work_email = Email()
    
    def __init__(self, email, work_email):
        self.email = email
        self.work_email = work_email
In [250]:
p = Person("alice@example.com", "work@example.com")
__set__ alice@example.com
__set__ work@example.com
In [248]:
p.email
__get__
Out[248]:
'alice@example.com'

Problem: Implement a my_property decorator that works like built-in property.

In [251]:
class my_property(object):
    def __init__(self, func):
        self.func = func
        
    def __get__(self, obj, cls):
        if obj is None:
            return self
        print("my_property.__get__")
        return self.func(obj)
    
In [252]:
class Person(object):
    @my_property
    def hello(self):
        return "Helllo"
In [253]:
p = Person()
In [254]:
p.hello
my_property.__get__
Out[254]:
'Helllo'

staticmethod and classmethod

In [266]:
class Person(object):
    def __init__(self, firstname, lastname):
        self.firstname = firstname
        self.lastname = lastname
        
    @staticmethod
    def parse(fullname):
        first, last = fullname.split(" ", 1)
        return Person(first, last)    
In [267]:
p = Person.parse("Guido van Rossum")
In [268]:
p.parse("Guido van Rossum")
Out[268]:
<__main__.Person at 0x1087697f0>
In [270]:
Person.__dict__
Out[270]:
mappingproxy({'__dict__': <attribute '__dict__' of 'Person' objects>,
              '__doc__': None,
              '__init__': <function __main__.Person.__init__>,
              '__module__': '__main__',
              '__weakref__': <attribute '__weakref__' of 'Person' objects>,
              'parse': <staticmethod at 0x108774e80>})
In [271]:
import datetime

class MyDateTime(datetime.datetime):
    pass
In [272]:
datetime.datetime.now()
Out[272]:
datetime.datetime(2017, 8, 18, 14, 16, 58, 936429)
In [273]:
MyDateTime.now()
Out[273]:
MyDateTime(2017, 8, 18, 14, 17, 15, 556228)
In [280]:
class A(object):
    def __init__(self, x):
        self.x = x
        
    def __repr__(self):
        return "<{} {}>".format(self.__class__.__name__, repr(self.x))

    @staticmethod
    def parse1(value):
        return A(int(value))

    @classmethod
    def parse2(cls, value):
        return cls(int(value))

class B(A):
    pass
        
In [276]:
a = A.parse1("123")
In [277]:
a
Out[277]:
<A 123>
In [278]:
b = B.parse1("123")
In [279]:
b
Out[279]:
<A 123>
In [281]:
a = A.parse2("123")
b = B.parse2("123")
print(a, b)
<A 123> <B 123>

Python 2 and Python 3

Major differences:

  • print is a function in Python 3
  • handling of bytes and text
  • no old-style classes
  • standard library is reorganized
In [286]:
%%file py2.py

for c in "helloworld":
    print c,
    
f = open("/tmp/a.txt", "w")
for c in "helloworld":
    print >> f, c
f.close()
Overwriting py2.py
In [287]:
!python2.7 py2.py
h e l l o w o r l d
In [288]:
!cat /tmp/a.txt
h
e
l
l
o
w
o
r
l
d
In [291]:
%%file py3.py

for c in "helloworld":
    print(c, end=" ")
    
f = open("/tmp/b.txt", "w")
for c in "helloworld":
    print(c, file=f)
f.close()    
Overwriting py3.py
In [293]:
!python3 py3.py
h e l l o w o r l d 
In [294]:
!cat /tmp/b.txt
h
e
l
l
o
w
o
r
l
d

How to write code that works in both Python 2 and Python 3

In [298]:
%%file x1.py
from __future__ import print_function

print("hello", "world", sep="-")
Writing x1.py
In [300]:
!python3 x1.py
hello-world
In [301]:
!python2.7 x1.py
hello-world
In [310]:
%%file x2.py

try:
    from urllib.request import urlopen
except ImportError:
    # python 2
    from urllib import urlopen

response = urlopen("http://httpbin.org/get")
print(response.read().decode('utf-8'))
Overwriting x2.py
In [311]:
!python3 x2.py
{
  "args": {}, 
  "headers": {
    "Accept-Encoding": "identity", 
    "Connection": "close", 
    "Host": "httpbin.org", 
    "User-Agent": "Python-urllib/3.5"
  }, 
  "origin": "27.63.8.148", 
  "url": "http://httpbin.org/get"
}

In [312]:
!python2.7 x2.py
{
  "args": {}, 
  "headers": {
    "Connection": "close", 
    "Host": "httpbin.org", 
    "User-Agent": "Python-urllib/1.17"
  }, 
  "origin": "27.63.8.148", 
  "url": "http://httpbin.org/get"
}

In [313]:
%%file utils.py

try:
    from urllib.request import urlopen
except ImportError:
    # python 2
    from urllib import urlopen
Writing utils.py
In [314]:
%%file x2a.py

from utils import urlopen

response = urlopen("http://httpbin.org/get")
print(response.read().decode('utf-8'))
Writing x2a.py

Multi-threading

In [315]:
%%file t1.py

import threading

def task():
    print("Hello ", threading.currentThread().getName())
    
def main():
    t1 = threading.Thread(target=task)
    t1.start()
    t1.join()
    
if __name__ == "__main__":
    main()
Writing t1.py
In [316]:
!python t1.py
Hello  Thread-1

Let us run it 10 threads

In [321]:
%%file t2.py
import threading

def task():
    print("Hello ", threading.currentThread().getName())
    
def main():
    threads = [threading.Thread(target=task) for i in range(10)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    
if __name__ == "__main__":
    main()
Overwriting t2.py
In [322]:
!python t2.py
Hello  Thread-1
Hello  Thread-2
Hello  Thread-3
Hello  Thread-4
Hello  Thread-5
Hello  Thread-6
Hello  Thread-7
Hello  Thread-8
Hello  Thread-9
Hello  Thread-10
In [327]:
%%file counter.py
import threading

class Counter:
    def __init__(self):
        self.count = 0
        
    def tick(self):
        self.count += 1
    
def task(counter, n):
    for i in range(n):
        counter.tick()
        
def main():
    counter = Counter()
    n = 100000
    nthreads = 10
    threads = [threading.Thread(target=task, args=(counter, n)) for i in range(nthreads)]
    
    for t in threads:
        t.start()
        
    for t in threads:
        t.join()
        
    print(counter.count)

if __name__ == "__main__":
    main()
Overwriting counter.py
In [328]:
!python counter.py
673757
In [329]:
!python counter.py
495542
In [332]:
%%file counter2.py
import threading

class Counter:
    def __init__(self):
        self.count = 0
        self.lock = threading.Lock()
        
    def tick(self):
        with self.lock:
            self.count += 1
    
def task(counter, n):
    for i in range(n):
        counter.tick()
        
def main():
    counter = Counter()
    n = 100000
    nthreads = 10
    threads = [threading.Thread(target=task, args=(counter, n)) for i in range(nthreads)]
    
    for t in threads:
        t.start()
        
    for t in threads:
        t.join()
        
    print(counter.count)

if __name__ == "__main__":
    main()
Overwriting counter2.py
In [333]:
!python counter2.py
1000000
In [334]:
!time python counter.py
710468

real	0m0.541s
user	0m0.518s
sys	0m0.018s
In [335]:
!time python counter2.py
1000000

real	0m12.402s
user	0m4.790s
sys	0m12.535s

Example: Downloading urls in parallel

In [336]:
!seq 10 | xargs printf "http://httpbin.org/get?x=%d\n"
http://httpbin.org/get?x=1
http://httpbin.org/get?x=2
http://httpbin.org/get?x=3
http://httpbin.org/get?x=4
http://httpbin.org/get?x=5
http://httpbin.org/get?x=6
http://httpbin.org/get?x=7
http://httpbin.org/get?x=8
http://httpbin.org/get?x=9
http://httpbin.org/get?x=10
In [337]:
!seq 10 | xargs printf "http://httpbin.org/get?x=%d\n">urls.txt
In [338]:
%%file sget.py
import sys
from urllib.request import urlopen

def get_urls(filename):
    return [line.strip() for line in open(filename)]

def wget(url):
    return  urlopen(url).read()

def main():
    filename = sys.argv[1]
    for url in get_urls(filename):
        wget(url)
        
if __name__ == "__main__":
    main()
    
Writing sget.py
In [339]:
!time python sget.py urls.txt
real	0m9.195s
user	0m0.146s
sys	0m0.032s
In [342]:
%%file pget.py
import sys
from sget import get_urls, wget
from multiprocessing.pool import ThreadPool

def main():
    filename = sys.argv[1]
    concurrency = int(sys.argv[2])
    urls = get_urls(filename)
    pool = ThreadPool(concurrency)
    pool.map(wget, urls)
    
if __name__ == "__main__":
    main()
Overwriting pget.py
In [343]:
!time python pget.py urls.txt 1
real	0m14.692s
user	0m0.173s
sys	0m0.041s
In [344]:
!time python pget.py urls.txt 2
real	0m13.174s
user	0m0.170s
sys	0m0.039s
In [345]:
!time python pget.py urls.txt 4
real	0m10.044s
user	0m0.169s
sys	0m0.038s
In [346]:
!time python pget.py urls.txt 5
real	0m8.475s
user	0m0.170s
sys	0m0.041s

Multi-Processing

In [353]:
%%file pcpu.py

import sys
from multiprocessing.pool import Pool

def task(dummy):
    sum = 0
    for i in range(1000):
        for j in range(10000):
            sum += 1.0* i *j
    return sum

def main():
    conc = int(sys.argv[1])
    pool = Pool(conc)
    pool.map(task, range(10))

if __name__ == "__main__":
    main()
Overwriting pcpu.py
In [354]:
!time python pcpu.py 1
real	0m24.840s
user	0m24.751s
sys	0m0.057s
In [357]:
!time python pcpu.py 4
real	0m14.738s
user	0m49.905s
sys	0m0.142s
In [356]:
!time python pcpu.py 5
real	0m14.465s
user	0m54.651s
sys	0m0.159s
In [355]:
!time python pcpu.py 2
real	0m18.152s
user	0m30.018s
sys	0m0.098s

Feedback

Please fill the feedback form to let us know how you felt about the course.

Feedback Form

References

Google for:

In [ ]: