4. More Control Flow Tools

Control flow - some information about control flow.

if x < 0:
    ...
elif x == 0:
    ...
else:  # optional, sort of default
    print('More')

If you’re comparing the same value to several constants, or checking for specific types or attributes, you may also find the match statement useful.

For statement in Python iterates over the sequence items, in the order that they appear. And finish when sequence is exhausted.

[print(x, end="😮") for x in list("Inomoz")]  # I😮n😮o😮m😮o😮z😮
 
for item in [1, 2, 3, 4, 5]:
    print(item)
else:
    print("Done!")  # if no break, this will be printed

Code that modifies a collection while iterating over that same collection can be tricky to get right. Instead, it is usually more straight-forward to loop over a copy of the collection or to create a new collection:

# Create a sample collection
users = {'Hans': 'active', 'Éléonore': 'inactive', '景太郎': 'active'}
 
# Strategy:  Iterate over a copy
# this looks interesting, it's avoid dict size changing during iteration
# how effective it is?
for user, status in users.copy().items():
    if status == 'inactive':
        del users[user]
print(users)
 
# Strategy:  Create a new collection
active_users = {}
for user, status in users.items():
    if status == 'active':
        active_users[user] = status
print(active_users)

range(X) function is sort of converting given length input to known array (0,1,...,X-1). It generates mathematics arithmetic progression.

If fact range() function generate equal to argument number (if passed only one) elements, we just start from 0 and increment by 1.

Last index of range(10)?::9, from 0 to 9

print(list(range(10))) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
                       # ten isn't included in the generated sequence.
                       # because in that case we generate 11 items
 
# Multiplication table 2x2 to 9*9
for i in range(2, 10):
  for j in range(2, 10):
    print(f"{i} * {j} = {i*j}")

You can select range start number (not index). Some like to generate numbers from X to Y-1.

print(list(range(5, 10)))  # [5, 6, 7, 8, 9]
print(list(range(5, 10))[4]) # 9

How to generate numbers form 1 to 100 using range()?

print(list(range(1, 101)))

And you can also specify increment for step size. Which can be positive or negative. Positive step size will generate sequence in increasing order and negative step size will generate sequence in decreasing order (alternative to reversed() function).

print(list(range(0, 10, 1)))  # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], default
print(list(range(0, 10, 2)))  # [0, 2, 4, 6, 8], 0 + 2, 2 + 2, 4 + 2, ..., two here is next number after 0, then 2 + 2, 4 + 2, ...
print(list(range(0, 10, 3)))  # [0, 3, 6, 9]
 
# Negative step size will generate sequence in reverse order.
# can be used as alternative to `reversed()`
print(list(range(10, 0, -1)))  # [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
print(list(range(-10, -100, -30))) # [-10, -40, -70], -10 - 30
print(list(range(-10, 10, 30))) # [-10], first item only
print(list(range(-10, 40, 30))) # [-10, -20], -10 + 30

What you will see in interactive python session if you enter this: list(range(10, 0)), list(range(10, 0, -1)) and this list(range(10, -1, -1)).

list(range(10, 0))  # [] empty list, because start is greater than stop
list(range(10, 0, -1))  # [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
list(range(10, -1, -1))  # [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

To iterate over the indices of a sequence, you can combine range() and len() as follows:

a = ['Mary', 'had', 'a', 'little', 'lamb']
for i in range(len(a)):
    print(i, a[i])

But better just use enumerate() function when you want to repeatedly access to iterable items (more suited for this).

a = ['Mary', 'had', 'a', 'little', 'lamb']
for i, item in enumerate(a):
    print(i, item)

If we don’t iterate over range and just print it, we will get object reference. Because range return iterable object, to get list we need to use ==list()== function.

print(range(10))  # range(0, 10)

What you will see with this code: sum(range(4))?
0 + 1 + 2 + 3 = 6

The break statement, like in C, breaks out of the innermost enclosing ==for or while loop==.

In python for and while statements may have a else clause. It is executed if loop ==wasn’t breaked== (we iterated over all items in for or while condition become False).

for n in range(2, 10):
    # multiple loops
    for x in range(2, n):
        if n % x == 0:
            print(n, 'equals', x, '*', n//x)
            break
    else:
        # loop fell through without finding a factor
        print(n, 'is a prime number')

When else runs in try-except statement?
In try-except statement, else runs if no exception was raised. If you worked on something (try, for, while) and you aren’t interrupt (break/exception), we call else method as this is likely unusual?

The ==continue== statement - continue loop with the next iteration of the loop (skip anything from current place to end of loop body).

for num in range(2, 10):
    if num % 2 == 0:
        print("Found an even number", num)
        continue
    print("Found an odd number", num)
for num in range(2, 10):
    if num % 2 == 0:
        print("Found an even number", num)
    else:
        print("Found an odd number", num)

pass is a ==null operation== (name, this is statement, while None is an object). When it is executed, nothing happens. It is useful as a placeholder when a statement is required syntactically, but no code needs to be executed.

while True:
    pass  # Busy-wait for keyboard interrupt (Ctrl+C)
\
def f(arg): pass    # a function that does nothing (yet)
print(f(1))
\
class C: pass       # a class with no methods (yet)
c = C()
print(c)
\
try:
    import platform
except ImportError:
    pass  # this is bad pracitce DO NOT DO THIS IN REAL CODE

A match statement comparig expression with successive patterns (state). This is pattern matching like in Rust or Haskell.

def http_error(status):
    match status:
        case 200: # here we compare status with 200 literal
            return "OK"
        case 400:
            return "Bad request"
        case 404:
            return "Not found"
        case 418:
            return "I'm a teapot"
        case 500 | 503 | 504: # multiple literals in one pattern, `|` is "or"
            return "Not allowed"
        case _:  # _ is a wildcard pattern and never fails to match
            return "Something's wrong with the internet"

Only the first pattern that matches gets executed, and it can also extract components (sequence elements or object attributes) from the value into variables in case block.

TODO: need improve Can I use objects (classes for example) in pattern matching, can I bind class attributes to variables?
Yes, you can use objects (in case blocks), place them with arguments (like a constructor), capturing class attributes also supported.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
def where_is(point):
    match point:
        case Point(x=0, y=0):
            print("Origin, coordinates are 0, 0")
        case Point(x=0, y=y_exctracted):
            print(f"Y coordinate: {y_exctracted}")
        case Point(x=x_exctracted, y=0):
            print(f"X coordinate: {x_exctracted}")
        case Point():
            print("Somewhere else")
        case _:
            print("Not a point")
 
for point in [Point(0, 0), Point(0, 1), Point(1, 0), Point(1, 1), None]:
    where_is(point)

Patterns can look like unpacking assignments (like tuple), and can be used to bind variables into case block:

point = (0, 100)
 
match point:
    case (0, 0):
        print("Origin")
    case (0, y):  # combine literal and variable, y bind point second value
        print(f"Y={y}")
    case (x, 0):
        print(f"X={x}")
    case (x, y):  # conceptualy similar to unpacking assigment
        print(f"X={x}, Y={y}")
    case _:
        raise ValueError("Not a point")

Can I customize class arguments order with pattern matching?
Yes, by setting the __match_args__ special attribute in your classes. If it’s set to ("x", "y") tuple, the following patterns are all equivalent (and all bind the y attribute to the var variable):

Point(1, var)
Point(1, y=var)
Point(x=1, y=var)
Point(y=var, x=1)

Which variable names can be assigned (bind variable to value) to in a match statement?
Only standalone names (like var) are assigned to by a match statement. Dotted names (like foo.bar), attribute names (the x= and y=) or class names (recognized by the ”(…)” next to them like Point above) are never assigned to.

Can patterns in match case be nested?

Yes, patterns can be arbitrarily nested. For example, if we have a short list of Points, with __match_args__ added, we could match it like this:

class Point:
    __match_args__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
for points in [
  [],
  [Point(0, 0)],
  [Point(1, 2)],
  [Point(0, 1), Point(0, 2)],
  [[Point(0, 1), Point(0, 2)], None]
]:
  match points:
      case []:
          print("No points")
      case [Point(0, 0)]:
          print("The origin")
      case [Point(x, y)]:
          print(f"Single point {x}, {y}")
      case [Point(0, y1), Point(0, y2)]:
          print(f"Two on the Y axis at {y1}, {y2}")
      case [[Point(0, y1), Point(0, y2)], None]:
          print("Inner list on the Y axis at {y1}, {y2}")
      case _:
          print("Something else")

Can I use if statements in pattern matching, why it can be useful?
Yes, if clause in pattern known as a “guard”. If the guard is false, match goes on to try the next case block. Note that value capture happens before the guard is evaluated:

for point in [Point(0, 0), Point(0, 1), Point(1, 0), Point(1, 1)]:
  match point:
      case Point(x, y) if x == y:
          print(f"Y=X at {x}")
      case Point(x, y):
          print(f"Not on the diagonal")

Several other key features of this statement:

  • Like unpacking assignments, tuple and list match patterns (case block) have exactly the same meaning and actually match arbitrary sequences. An important exception is that they don’t match iterators or strings. In other words you can’t unpack for example "00" value with (x, y) pattern.
def where_is(point):
    match point:
        case (0, 0):
            print("Origin")
        case (x, y):
            print(f"X={x}, Y={y}")
        case _:
            print("Somewhere else")
 
where_is((0, 0))              # Origin
where_is("00")                # Somewhere else
where_is(iter([0, 1]))        # Somewhere else
where_is(list(iter([0, 1])))  # X=0, Y=1
  • Sequence pattern matching support extended unpacking: [x, y, *rest] and (x, y, *rest) work similar to unpacking assignments. The name after * may also be _, so (x, y, *_) matches a sequence of at least two items without binding the remaining items.
data = [0, 1, 2, 3]
 
match data:
    case [a, b, *rest]:  # you can use other name, not only rest
        print(a, b, rest)
 
match data:
    case [a, b, *_]: # just igonore rest values
        print(a, b)
  • Mapping patterns: {"bandwidth": b, "latency": l} captures the "bandwidth" and "latency" values from a dictionary and save them into b and l variables. Unlike sequence patterns, extra keys are ignored. An unpacking like **rest is also supported. But ==**_== would be redundant, so it is not allowed.
ages = {"mike": 1, "kelly": 2, "ivan": 3, "petr": 4}
match ages:
    case {"mike": mike, "kelly": kelly, "ivan": ivan, **rest}:
        print(mike, kelly, ivan, rest)  # 1 2 3 {'petr': 4}
  • Subpatterns may be captured using the ==as== keyword, sort of alternative name:
class Point:
    __match_args__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
def test_match(data):
    match data:
        # will capture the second element of the input as `p2`
        # (as long as the input is a sequence of two points)
        case (Point(x1, y1), Point(x2, y2) as p2):
            print(x1, y1, p2.x, p2.y)
        case _:
            print("Unknown")
 
test_match((Point(0, 1), Point(2, 3)))
test_match((Point(0, 1)))
  • Most literals are compared by equality in pattern matching, however the singletons (which one) ==True, False and None== are compared by identity
none_item = None
match none_item:
    case "":
        print("Empty string")
    case None:
        print("None")  # <- None

How to use named constants (Enum) in pattern matching?

  • Patterns may use named constants. These must be dotted names to prevent them from being interpreted as capture variable:
from enum import Enum
class Color(Enum):
    RED = 'red'
    GREEN = 'green'
    BLUE = 'blue'
 
color = Color(input("Enter your choice of 'red', 'blue' or 'green': "))
 
match color:
    case Color.RED:
        print("I see red!")
    case Color.GREEN:
        print("Grass is green")
    case Color.BLUE:
        print("I'm feeling the blues :(")

PEP 636 – Structural Pattern Matching: Tutorial.

We can create a function that writes the Fibonacci series to an arbitrary boundary, can you explain how (function code)?

def fib(n):    # write Fibonacci series up to n
    """Print a Fibonacci series up to n."""
    a, b = 0, 1
    while a < n:
        print(a, end=' ')
        a, b = b, a+b
    print()
 
# Now call the function we just defined:
fib(2000) # 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597

How to define a function in Python?
The keyword def introduces a function definition. Example of function definition:

def hello():
    print("Hello, World!")

The first statement of the function body can optionally be a string literal (usually in triple quotes); this string literal is the function’s documentation string, or ==docstring==.

Why include docstring is recommended to make a habit?
There some tools, which produce online or printed documentation, or to let the user interactively browse through code. Also, many IDEs and editors support showing documentation based on this docstrings.

Order of variable references looking (tables)?

  • Local symbol table
  • Local symbol table of enclosing functions (parents)
  • Global symbol table
  • Built-in names table These priorities also reason why global and nonlocal statements are needed.

How to change (or use) variable from global scope in some function, when are you trying to assign value to it, python create new local variable with the same name?
Use global statement.

x = 0
print(x)
def f():
    global x # if you don't use this, parent x will be not changed
    x = 1
f()
print(x)

The nonlocal statement causes the listed identifiers to refer to previously bound variables in the nearest enclosing scope excluding globals.

Closures in nested functions: we can use the nonlocal keyword to work with variables in nested scope which shouldn’t be declared in the inner functions.

def create_avg():
    total = 0
    count = 0
    def avg(n):  # Closure
        nonlocal total, count  # closure use variables defined in outer scope
        total += n
        count += 1
        return total/count
    return avg
avg = create_avg()
avg(3)  # => 3.0
avg(5)  # (3 + 5) / 2 => 8 / 2  => 4.0
avg(7)  # (8 + 7) / 3 => 15 / 3 => 5.0

When a function calls another function, or calls itself recursively, a new local symbol table is created for that call (related to arguments/variables).

The actual parameters (arguments) to a function call are introduced in the local symbol table of the called function when it is called; thus, arguments are passed using call by value, true for immutable objects (where the value is always an object reference, not the value of the object). Actually, call by object reference would be a better description, since if a mutable object is passed, the caller will see any changes the callee makes to it (items inserted into a list).

A function definition associates the function name with the function object in the current symbol table.

a = 0
print(locals())
def hello():
    a = 1
    print("Hello, World!")
    print(locals())
print(locals())
f = hello
f()

Is there procedures in Python?
In Python there no procedures, only functions. Functions without a return statement do return a value. This value is called None (it’s a built-in name).

What is object method in Python?
A method is a function that ‘belongs’ to an object and is named obj.methodname, where obj is some object (this may be an expression), and methodname is the name of a method that is defined by the object’s type.

You can define default values for arguments in a function, when it can be required?
This allows to omit some arguments when calling the function, sort of default settings.

def ask_ok(prompt, retries=4, reminder="Please try again, I don't understand you!"):
    while True:
        reply = input(prompt)
        if reply in {"y", "ye", "yes"}:
            return True
        if reply in {"n", "no", "nop", "nope"}:
            return False
        retries = retries - 1
        if retries < 0:
            raise ValueError("invalid user response")
        print(reminder)
ask_ok("Do you really want to quit?\n")
ask_ok("OK to overwrite the file?\n", 2)  # 2 retries
ask_ok("OK to overwrite the file?\n", 2, "Come on, only yes or no!")  # 2 retries, custom reminder

in keyword tests that a sequence contains a certain value.

What this code will print (question about default value behavior and evaluation)?

# Example 1
i = 5
 
def f(arg=i):
    print(arg)
 
i = 6
f()
 
# Example 2
def f2(a, L=[]):
    L.append(a)
    return L
 
print(f2(1))
print(f2(2))
print(f2(3))
 
# Example 3
def f3(a, L=None):
    if L is None:  # You can pass not None value to L
        L = []
    L.append(a)
    return L
print(f3(1))
print(f3(2))
print(f3(3))


The default values are evaluated at the point of function definition in the defining scope, so that code will print 5, so order of evaluation is important, and default value evaluated only once if it’s not mutable object (list, dictionary, or instances of most classes). Last example if you want default to be not shared between subsequent calls.

Functions can also be called using keyword arguments, kwarg=value.

def parrot(voltage, state='a stiff', action='voom', type='Norwegian Blue'):
    print("-- This parrot wouldn't", action, end=' ')
    print("if you put", voltage, "volts through it.")
    print("-- Lovely plumage, the", type)
    print("-- It's", state, "!")
 
# Valid function calls:
parrot(1000)                                          # 1 positional argument
parrot(voltage=1000)                                  # 1 keyword argument
parrot(voltage=1000000, action="VOOOOOM")             # 2 keyword arguments, order 1
parrot(action="VOOOOOM", voltage=1000000)             # 2 keyword arguments, order 2
parrot("a million", "bereft of life", "jump")         # 3 positional arguments
parrot("a thousand", state="pushing up the daisies")  # 1 positional, 1 keyword
 
# Invalid function calls:
parrot()                     # required argument missing
parrot(voltage=5.0, "dead")  # non-keyword argument after a keyword argument
parrot(110, voltage=220)     # duplicate value for the same argument
parrot(actor="John Cleese")  # unknown keyword argument

When a final formal parameter of the form **name is present, it receives a dictionary (see typesmapping) containing all keyword arguments except for those corresponding to a formal parameter.

This may be combined with a formal parameter of the form *name (described in the next subsection) which receives a tuple <tut-tuples> containing the positional arguments beyond the formal parameter list. (*name must occur before **name.) For example, if we define a function like this:

Length of tuple with positional arguments and dictionary with keyword arguments? Check cheeseshop function call.

def cheeseshop(kind, *arguments, **keywords):
    """
    *arguments, formal parmater which recieves a tuple with positional arguments
    beyond the formal parameter list.
    --
    **keywords, final formal parameter, receives a dictionary.
    """
    print("Arguments length: ", len(arguments))
    print("Keywords length: ", len(keywords))
    print("-- Do you have any", kind, "?")
    print("-- I'm sorry, we're all out of", kind)
    # NOTE: keyword arguments are printed how they were provided in the function
    # call.
    for arg in arguments:
        print(arg)
    print("-" * 40)
    for kw in keywords:
        print(kw, ":", keywords[kw])
 
cheeseshop("Limburger", "It's very runny, sir.",
           "It's really very, VERY runny, sir.",
           shopkeeper="Michael Palin",
           client="John Cleese",
           sketch="Cheese Shop Sketch")


Two positional arguments and three keyword arguments.

Positional-only, positional-or-keyword, and keyword-only parameters can be combined in a single function definition, how to do this?
A function definition may look like this:

# If `/` and `*` are not present, arguments may be passed
# by position (`f(1, 2, 3, 4, 5)) or by keyword (`f(pos1=1, pos2=2, pos_or_kwd=3, kwd1=4, kwd2=5)`).
#
# `/` and `*` are optional. If used, these symbols indicate the kind of
# parameter by how the arguments may be passed to the function:
# positional-only, positional-or-keyword, and keyword-only.
#
# first [] block is positional-only, second [] block is keyword-only
# in the middle is positional-or-keyword
def f([pos1, pos2], /, pos_or_kwd, *, [kwd1, kwd2]):
      -----------    ----------     ----------
        |             |                  |
        |        Positional or keyword   |
        |                                - Keyword only
         -- Positional only
 
def standard_arg(arg):  # position or keyword
    print(arg)
standard_arg(2)
standard_arg(arg=2)
 
def pos_only_arg(arg, /):  # only positional parameters
    print(arg)
pos_only_arg(1)
pos_only_arg(arg=1)  # TypeError: pos_only_arg() got some positional-only arguments passed as keyword arguments: 'arg'
 
def kwd_only_arg(*, arg):  # only keyword arguments
    print(arg)
kwd_only_arg(arg=3)
kwd_only_arg(3)  # TypeError: kwd_only_arg() takes 0 positional arguments but 1 was given
 
 
def combined_example(pos_only, /, standard, *, kwd_only): # all three calling conventions
    print(pos_only, standard, kwd_only)
combined_example(1, 2, kwd_only=3)
combined_example(1, standard=2, kwd_only=3)
combined_example(1, 2, 3)  # TypeError: combined_example() takes 2 positional arguments but 3 were given
combined_example(pos_only=1, standard=2, kwd_only=3)  # TypeError: combined_example() got some positional-only arguments passed as keyword arguments: 'pos_only'

Will this code work, if not why and how to fix it?

def foo(name, **kwds):
    return 'name' in kwds
foo(1, **{'name': 2})


Since the name parameter is a positional and keyword argument, it will always bind to the first parameter (name). So, the code will raise a TypeError: foo() got multiple values for argument ‘name’. You can fix this by using / (positional-only arguments) to allow name as a positional argument only, the names of positional-only parameters can be used in **kwds without ambiguity.

def foo(name, /, **kwds):
    return 'name' in kwds
foo(1, **{'name': 2})  # True

When positional-only arguments are useful in function definition?
Use positional-only if you want the name of the parameters to not be available to the user. This is useful when parameter names have no real meaning, if you want to enforce the order of the arguments when the function is called or if you need to take some positional parameters and arbitrary keywords.

When keyword-only arguments in function definition are useful?
Use keyword-only when names have meaning and the function definition is more understandable by being explicit with names or you want to prevent users relying on the position of the argument being passed.

For an API, use positional-only to prevent breaking API changes if the parameter’s name is modified in the future. But don’t forget about too many positional-only parameters, it can make the function hard to understand.

You can specify that a function can be called with an arbitrary number of arguments. These arguments will be wrapped up in a tuple. Before the variable number of arguments, zero or more normal arguments may occur.

def write_multiple_items(file, separator, *args):
    file.write(separator.join(args))

Normally, these variadic arguments will be last in the list of formal parameters, because they scoop up all remaining input arguments that are passed to the function. Any formal parameters which occur after the *args parameter are ‘keyword-only’ arguments, meaning that they can only be used as keywords rather than positional arguments. :

def concat(*args, sep=”/”): … return sep.join(args) … concat(“earth”, “mars”, “venus”) ‘earth/mars/venus’ concat(“earth”, “mars”, “venus”, sep=”.“) ‘earth.mars.venus’

The reverse situation occurs when the arguments are already in a list or tuple but need to be unpacked for a function call requiring separate positional arguments. For instance, the built-in range function expects separate start and stop arguments. If they are not available separately, write the function call with the *-operator to unpack the arguments out of a list or tuple:

Sometimes you need to unpack data from list or tuple for a function call, which require separate positional arguments, for example range function expects start and stop arguments separately. You can write the function call with the ==*-operator, list(range(*args)) to unpack the arguments out of a list or tuple.

Dictionaries can deliver keyword arguments with the ==**==-operator.

def parrot(voltage, state='a stiff', action='voom'):
    print("-- This parrot wouldn't", action, end=' ')
    print("if you put", voltage, "volts through it.", end=' ')
    print("E's", state, "!")
 
# Default method to provide keyword arguments
parrot(voltage="four million", state="bleedin' demised", action="VOOM")
 
# Pass kwargs with all keyword argmunts
d = {"voltage": "four million", "state": "bleedin' demised", "action": "VOOM"}
parrot(**d)

Small anonymous functions can be created with the ==lambda== keyword. They can be used wherever function objects are required. Syntactically restricted to a single expression, semantically they are just syntactic sugar for a normal function definition.

Lambda function format?
lambda arguments : expression

x = lambda a : a + 10
print(x(5))  # 15
 
x = lambda a, b : a * b
print(x(5, 6))  # 30
 
x = lambda a, b, c : a + b + c
print(x(5, 6, 2))  # 13

Like nested function definitions, lambda functions can reference variables from the containing scope.

def make_incrementor(n):
    # Use a lambda expression to return a function.
    return lambda x: x + n
 
f = make_incrementor(42)
f(0) # 42, f holds 42 and retruns 42 + 0
f(1) # 43

How to use function/lambda function with list and sort method?

# Lambda function as an argument, to sort method
pairs = [(1, 'one'), (2, 'two'), (3, 'three'), (4, 'four')]
pairs.sort(key=lambda pair: pair[1])
pairs # [(4, 'four'), (1, 'one'), (3, 'three'), (2, 'two')]
 
def sort_pairs_func(pair):
    return pair[1]
pairs = [(1, 'one'), (2, 'two'), (3, 'three'), (4, 'four')]
pairs.sort(key=sort_pairs_func)
pairs # [(4, 'four'), (1, 'one'), (3, 'three'), (2, 'two')]

Documentation Strings used to document functions, methods, classes, and modules.

Docstring code style, at least basic information?
The first line should always be a short, concise summary of the object’s purpose. For brevity, it should not explicitly state the object’s name or type, since these are available by other means (except if the name happens to be a verb describing a function’s operation). This line should begin with a capital letter and end with a period.
If there are more lines in the documentation string, the second line should be blank, visually separating the summary from the rest of the description. The following lines should be one or more paragraphs describing the object’s calling conventions, its side effects, etc.
The Python parser does not strip indentation from multi-line string literals in Python, so tools that process documentation have to strip indentation if desired. This is done using the following convention. The first non-blank line after the first line (third) of the string determines the amount of indentation for the entire documentation string. (We can’t use the first line since it is generally adjacent to the string’s opening quotes so its indentation is not apparent in the string literal.) Whitespace “equivalent” to this indentation is then stripped from the start of all lines of the string. Lines that are indented less should not occur, but if they occur all their leading whitespace should be stripped. Equivalence of whitespace should be tested after expansion of tabs (to 8 spaces, normally).
Here is an example of a multi-line docstring: \

def my_function():
    """Do nothing, but document it.
 
    No, really, it doesn't do anything.
    """
    pass
 
print(my_function.__doc__)
Do nothing, but document it.
 
No, really, it doesn't do anything.

Function annotations <function> are completely optional metadata information about the types used by user-defined functions (see PEP 3107 - Function Annotations and PEP 484 - Type hints).

Function annotation are stored in the ==__annotations__== attribute of the function as a dictionary and have no effect on any other part of the function.

Parameter annotations are defined by a colon after the parameter name, followed by an expression evaluating to the value of the annotation.

Return annotations are defined by a literal ==->==, followed by an expression.

The following example has a required argument, an optional argument, and the return value annotated:

def f(ham: str, eggs: str = 'eggs') -> str:
    print("Annotations:", f.__annotations__)
    print("Arguments:", ham, eggs)
    return ham + ' and ' + eggs
 
f('spam')
"""
Annotations: {'ham': <class 'str'>, 'return': <class 'str'>, 'eggs': <class 'str'>}
Arguments: spam eggs
'spam and eggs'
"""

Python Coding Style

Most languages can be written (or more concise, formatted) in different styles; some are more readable than others. Making it easy for others to read your code is always a good idea, and adopting a nice coding style helps tremendously for that.

PEP 8 – Style Guide for Python Code core information:

Python code style usually use 4-space indentation, and no tabs (compromise between small and large indentation).

Wrap lines so that they don’t exceed 79 characters. Useful for small displays and also prevent you writing complex code.

Use blank lines to separate functions and classes, and larger blocks of code inside functions, sort of grouping.

When possible, put comments on a line of their own (separate line).

How to document code in Python?
Use docstrings, which follow docstrings rules.

Use spaces around operators and after commas, but not directly inside ==bracketing constructs: a = f(1, 2) + g(3, 4)==.

Name your classes and functions consistently; the convention is to use UpperCamelCase for classes and lowercase_with_underscores for functions and methods. Always use self as the name for the first method argument.

Don’t use fancy encodings if your code is meant to be used in international environments. Python’s default, UTF-8, or even plain ASCII (subset) work best in any case.

Likewise, don’t use non-ASCII characters in identifiers if there is only the slightest chance people speaking a different language will read or maintain the code.