# Python Notes

## Basics

### Naming Convention

Class -> PascalCase  
Method, Function -> snake_case  
Variable -> snake_case

```py
# standard comment
'''multiline comment'''
"""DOCSTRING"""

help(object.method) # return method explanation
dir(object) # return an alphabetized list of names comprising (some of) the attributes of the given object

import sys # import module
from sys import argv # import single item from a module
from sys import * # import all elements of a module (no module syntax.method needed)
import sys as alias # import the module with an alias, I use alias.method

# CHARACTER SET
import string
string.ascii_lowercase = 'abcdefghijklmnopqrstuvwxyz'
string.asci_uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
string.asci_letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
string.digits = '0123456789'
string.hexdigits = '0123456789abcdefABCDEF'
string.octdigits = '01234567'
string.punctuation
string.whitespace

# SPECIAL CHARACTERS
# (\a, \b, \f, \n, \r, \t, \u, \U, \v, \x, \\)
```

### Assignment Operation

```py
"""instructions to the right of = executed before instructions to the left of ="""
variable = expression # the type of the variable is dynamically decided by python based on the content
var_1, var_2 = value1, value2 # parallel assignment
var_1, var_2 = var_2, var_1 # swap values

# conditional assignment
x = a if condition else b
x = a or b # If bool (a) returns False, then x is assigned the value of b
# a series of OR expressions has the effect of returning the first item that evaluates True, or the last item (last item should be a literal).
```

### Variable Type Conversion

`type(expression)`

### Expression Assignment

```py
(var: = expression) # assign an expression to a variable to avoid repeating the expression
```

### Variable Comparison (`==` vs `is`)

`==` compares the values ​​of objects
`is` compares the identities of objects

### On Screen Output

```py
print() # print blank line and wrap
print('string' * n) # print string n times
print('string1 \ n string2') # wrap with \ n
print(variable) # print variable content
print('string', end = '') # print without wrapping

# FORMATTING
name = 'Alex'
marks = 94.5
print(name, marks)
print('Name is', name, '\ nMarks are', marks)
# expand the rest of the expression and write tense before = in output
print(f '{name =}, {marks =}') # OUTPUT: name = Alex, marks = 94.5

# USE OF PLACEHOLDERS
print('Name is% s, Marks are% 3.2f'%(name, marks)) # method inherited from C. Variable is substituted for% ..
print("Name is {}, Marks are {}". format(name, marks))
print("Name is {1}, Marks are {2}". format(marks, name)) # indices in brackets sort elements in .format
print("Name is {n}, Marks are {m}". format(m = '94 .5 ', n =' Alex ')) # indices in brackets sort elements in .format
print(f'Name is {name}, Marks are {marks} ') # formatting with f-strings
```

### Format Specification Mini-Language

`{value:width.precision symbol}`

Format: `[[fill]align] [sign] [#] [width] [grouping] [.precision] [type]`

| `[align]` | Alignment              |
| --------- | ---------------------- |
| `:<`      | left alignment         |
| `:>`      | right alignment        |
| `:=`      | padding after the mark |
| `:^`      | centered               |

| `[sign]` | NUMBER SIGNS                                                                                                    |
| -------- | --------------------------------------------------------------------------------------------------------------- |
| `:+`     | sign for both positive and negative numbers                                                                     |
| `:-`     | sign only for negative numbers                                                                                  |
| `:`      | space for num > 0, '-' for num < 0                                                                              |
| `:#`     | alternative form:prefix integers type (0x, 0b, 0o), floats and complexes always have at least one decimal place |

| `[grouping]` | GROUPING                             |
| ------------ | ------------------------------------ |
| `:,`         | use comma to separate thousands      |
| `:_`         | use underscore to separate thousands |

| `[type]` | OUTPUT TYPE                                                                 |
| -------- | --------------------------------------------------------------------------- |
| `:s`     | output is string                                                            |
| `:b`     | output is binary                                                            |
| `:c`     | output is character                                                         |
| `:d`     | output is a decimal integer (base 10)                                       |
| `:or`    | output is octal integer (base 8)                                            |
| `:x`     | output is hexadecimal integer (base 16)                                     |
| `:X`     | output is hexadecimal integer (base 16) with uppercase                      |
| `:e`     | output is exponential notation (6-digit base precision)                     |
| `:E`     | output is exponential notation (6-digit base precision) uppercase separator |
| `:f`     | output is float (6-digit base precision)                                    |
| `:%`     | output is percentage (multiplies * 100, displays as:f)                      |

### Keyboard Input

```py
# input always returns a STRING
s = input() # input request without message
s = input('Prompt') # request input
i = int(input('prompt')) # request input with type conversion

# MULTIPLE INPUTS
list = [int(x) for x in input('prompt'). split('separator')]
# save multiple inputs in a list(.split separates values ​​and defines separator
```

## Numeric Types

```py
a = 77
b = 1_000_000 # underscore can be used to separate groups of digits
c = -69

# float numbers
x = 3.15
y = 2.71
z = 25.0

d = 6 + 9j # complex number
# returns a complex number starting with two reals
complex(real, imag) # -> complex #(real + imag * 1j)

e = 0B1101 # BINARY TYPE(0B ...)
f = 0xFF # EXADECIMAL TYPE(0X ...)
o = 0o77 # OCTAL TYPE
g = True # BOOLEAN TYPE

# VARIABLE TYPE CONVERSION
h = int(y)
i = float('22 .5 ')

# NUMERIC BASIC CONVERSION
bin(3616544)
hex(589)
oct(265846)

# UNICODE CONVERSION
ord(c) # Given a string representing one Unicode character, return an integer representing the Unicode code point of that character
chr(i) # Return the string representing a character whose Unicode code point is the integer i


pow(x, y) # x ^ y
abs(num) # returns absolute value of num(| num |)
round(num, precision) # rounds number to given precision, does not convert float to int
```

### Comparison of Decimal Numbers

Do not use `==` or `! =` To compare floating point numbers. They are approximations or have several digits.
It is worth checking if the difference between the numbers is small enough.

## Strings

```py

string = 'string content' # assignment and creation of string variable
string = '''multi
line
string'''

string3 = string1 + string2 # string concatenation(operator polymorphism +)

# INDEXING(selection of a character in the string)
string[0]
string[2]
string[-3] # selection starting from the bottom(negative index)

# REPETITION (repeat string output)
print(string * n)

len(string) # show the length of a string

# SLICING (extraction of sub-strings, does not include the position of the last index)
string[0: 5]
string[: 6]
string[-3: -1]

# SLICING WITH STEP
string[0: 12: 3]
string[15 :: - 1]
string[:: - 1] # selection in reverse order (negative step)

# STRIPPING (elimination of spaces before and after string)
string = 'stripping test'
string.strip()
string.lstrip() # only left spaces removed
string.rstrip() # only right spaces removed
string.removeprefix(prefix) # If the string starts with the prefix string, return string [len (prefix):]
string.removesuffix(suffix) # If the string ends with the suffix string and that suffix is ​​not empty, return string [: - len (suffix)]

# SUBSTRING IDENTIFICATION
#returns starting index of the substring or -1 if it is not present
string.find('substring', 0, len (string)) # you can specify the start and end index of the search

# COUNT OF APPARITIONS
string.count('t')

# REPLACEMENT
string.replace('multi', 'multiple')

# UPPER CASE CONVERSION
string.upper()
string.lower()
string.title()
string.capitalize()

# SEPARATION IN LIST ELEMENTS
string.split()
string.split('separator') # separate using separator (separator omitted in list)
string.partition('char') # -> tuple # separates the string from the 3 parts at the first occurrence of separator

# IS_CHECK METHODS -> bool
string.isalnum()
string.isalpha()
string.islower()
string.isspace()
string.istitle()
string.isupper()
string.endswith('char')

# JOIN INSTRUCTION()
''.join(iterable) # merges all elements of the iterable into the new string

# FORMATTING
string.center(width, 'char') # stretch the string with char to width
'...\t...'.expandtabs() # transform tabs into spaces
```

## Lists

```py
list = [9, 11, 'WTC', -5.6, True] # lists can contain data of different types

list[3] # indexing
list[3: 5] # slicing
list * 3 # repetition
len(list) # length
list3 = list1 + list2 # list concatenation (operator + polymorphism)
list[index] = value # modify list element
del (list [1]) # remove by index (INBUILT IN PYTHON)
# modify the list between the start and stop indices by reassigning the elements of the iterable
list[start: stop] = iterable

# LIST METHODS
list.append(object) # add object to background
list.count(item) # counts the number of occurrences of item
list.extend(sequence) # add sequence elements to the list
list.insert(position, object) # insert object in list [position]
list.index(item) # returns the index of item
list.remove(item) # remove item
poplist(item) # delete item and return it
list.clear() # remove all elements

list.sort() # sorts in ascending order (in place)
list.sort(reverse = True) # sorts in descending order (in place)
list.reverse() # invert the string (in place)

# CLONING
list1 = [...]
list2 = list1 # list2 points to the same object of list 1 (changes are shared)
list3 = list1 [:] # list3 is a clone of list1 (no shared changes)

# NESTED LISTS (MATRICES)
list_1 = [1, 2, 3]
list_2 = [4, 5, 6]
list_3 = [7, 8, 9]

matrix = [list_1, list_2, list_3]
matrix [i][j] # identify element of list_i index j

# MAXIMUM AND MINIMUM
max(list)
min(list)

# ALL () & ANY ()
all(sequence) # returns TRUE if all elements of the sequence are true
any(sequence) # returns TRUE if at least one element of the sequence has the value True

# MAP INSTRUCTION
# apply function to iterable and create new list (map object)
# function can be lambda
map(function, iterable) # -> map object

# FILTER INSTRUCTION ()
# create a new list composed of the iterable elements for which the function returns TRUE
filter(function, iterable) # -> filter object

# ZIP INSTRUCTION ()
# create a tuple generator by joining two or more iterables
# [(seq_1 [0], seq_2 [0], ...), (seq_1 [1], seq_2 [1], ...), ...]
# truncate the sequence to the length of the shortest input sequence
zip(seq_1, seq_2, ...) # -> zip object (tuple generator)

# LIST COMPREHENSIONS
var = [expression for element in sequence if condition] # create list from pre-existing list (instead of map, filter, reduce) applying any manipulations
# expression can be lambda, if is optional
var = [expression if condition else statement for element in sequence] # list comprehension with IF-ELSE
var = [expression_1 for element in [expression_2 for element in sequence]] # nested list comprehension
var = [(exp_1, exp_2) for item_1 in seq_1 for item_2 in seq_2] # -> [(..., ...), (..., ...), ...]
```

## Tuple

```py
# TUPLES CANNOT BE MODIFIED
tuple = (69, 420, 69, 'abc') # tuple assignment
tuple = (44,) # single element tuples need a comma

tuple[3] # indexing
tuple * 3 # repetition
tuple.count(69) # counting
tuple.index(420) # find index
len(tuple) # length tuple

# CONVERSION FROM TUPLE TO LIST
tuple = tuple(list)

# TUPLE UNPACKING
tup = (item_1, item_2, etc)
var_1, var_2, etc = tup
# var_1 = item_1, var_2 = item_2, ...

tup = (item_1, (item_2, item_3))
var_1, (var_2, var_3) = tup
# var_1 = item_1, var_2 = item_2, var_3 = item_3

#OPERATOR * VAR (tuple unpacking)
var_1, var_2, * rest = sequence # var_1 = seq [0], var_2 = seq [1], rest = seq [2:]
var_1, * body, var_2, var_3 = sequence # var_1 = seq [0], body = seq [1: -2], var_2 = sequence [-2], var_3 = seq [-1]
# * var retrieves the excess items, if in parallel assignment usable max once but in any position
```

## Set

```py
# SETS MAY NOT CONTAIN REPEATED ELEMENTS (THEY ARE OMITTED)
# THE ORDER DOES NOT MATTER (NO SLICING, INDEXING, REPETITION, ...)
set = {10, 20, 30, 'abc', 20}
len(set) # length set
set() # create empty set ({} create empty dictionary)
# FREEZING SETS (no longer editable)
fset = frozenset(set)

# OPERATORS
set_1 - set_2 # elements in set_1 but not in set_2
set_1 | set_2 # elements in set_1 or set_2
set_1 & set_2 # elements in set_1 and set_2
set_1 ^ set_1 # elements in either set_1 or set_2
set_1 <= set_2 # elements set_1 also in set_2
set_1 < set_2 # set_1 <= set_2 and set_1! = set_2
set_1 >= set_2 # elements set_2 also in set_1
set_1 > set_2 # set_1> = set_2 and set_1! = set_2

# METHODS SET
set.pop(item) # remove and return item
set.add(item) # add item to set

set.copy() # -> set # returns a copy of the set
set.clear() # remove all elements from the set
set.remove(item) # remove item from set if present, otherwise raise KeyError
set.discard(item) # remove item from set if present, otherwise do nothing
set.difference(* sets) # -> set # returns elements in set that are absent in * sets
set.difference_update(* sets) # remove differences from set_2
set.union(* sets) # -> set # returns all elements of sets
set.update(* sets) # add * sets elements to set
set.intersection(* sets) # -> set # returns the elements common to sets
set.intersection_update(* sets) # remove all elements except those common to sets
set.symmetric_difference(* sets) # -> set # returns elements not common to sets
set.symmetric_difference_update(* sets) # remove all elements common to sets (leave only uncommon elements)

set_1.isdisjoint(set_2) # -> bool # True if there are no common elements (intersection is empty)
set_1.issubset(set_2) # -> bool # True if every element of set_1 is also in set_2
set_1.issuperset(set_2) # -> bool # True if every element of set_2 is also in set_1

# SET COMPREHENSIONS
var = {expression for element in sequence if condition}

# SLICE OBJECT
# [start: stop: step] -> slice object (start, stop, step)
var_1 = slice(start, stop, step) # assignment to variable
var_2[var_1] # same as var_2 [start: stop: step]

# ELLIPSIS OBJECT
var[i, ...] # -> shortcut for var [i,:,:,:,]
# used for multidimensional slices (NumPy, ...)

```

## Bytes e Bytearray

```py
# THE BYTES CANNOT BE MODIFIED OR INDEXED
# THE BYTEARRAYS CAN BE MODIFIED AND INDEXED
# YOU CANNOT DO REPETITION AND SLICING ON BYTE OR BYTEARRAY

b = bytes(list)
ba = bytearray(list)

# item of bytes and bytearray is always integer between 0 and 255
# slice of bytes and bytearray is binary sequence (even if len = 1)

# BYTES AND BYTEARRAY METHODS
bytes.fromhex(pair_hex_digits) # -> byte literal
b'bite_literal'.hex() # -> str # returns a string containing hex digit pairs
bytearray.fromhex(pair_hex_digits) # -> byte literal
bytes.count(subseq, start, end) # returns subseq appearance count between start and end positions
bytearray.count(subseq, start, end) # returns subseq appearance count between start and end positions
```

## Encoding-Decoding & Unicode

Unicode Literals:

- `\u0041` --> 'A'
- `\U00000041` --> 'A'
- `\x41` --> 'A'

```py
# ENCODING
# transform string into literal byte
# UnicodeEncodeError on error
# errors = ignore -> skip error-causing characters
# errors = replace -> replace? to characters causing error
# errors = xmlcharrefreplace -> substitutes XML entities for error-causing characters
string.encode('utf-8', errors = 'replace') # -> b'byte literals'

# BOM (BYTE ORDER MARK)
# byte literal given to indicate byte ordering (little-endian vs big-endian)
# in little-endian the least significant bytes come first (e.g. U + 0045 -> DEC 069 -> encoded as 69 and 0)
# U + FEFF (ZERO WIDTH NO-BREAK SPACE) -> b '\ xff \ xfe' indicates little-endian

# DECODING
# transform byte literal to string
# error = 'replace' replaces errors (byte literals not belonging to decoding format) with U + FFFD "REPLACEMENT CHARACTER"
bytes.decode ('utf-8', errors = 'replace') # -> str

# UNICODE NORMALIZATION
# handling canonical unicode equivalents (e.g. é, and \ u0301 are equivalent for unicode)
unicodedata.normalize(form, unicode_string) # FORM: NFC, NFD, NFCK, NFDK
# NFC -> "Normalization Form C" -> produces the shortest equivalent string
# NFD -> "Normalization Form D" -> produces the longest equivalent string

# CASE FOLDING UNICODE
# transform to lowercase with some differences (116 differences, 0.11% of Unicode 6.3)
string.casefold()

# USEFUL FUNCTIONS FOR NORMALIZED EQUIVALENCE (Source: Fluent Python p. 121, Luciano Ramalho)
from unicodedata import normalize

def nfc_eual(str_1, str_2):
    return (normalize('NFC', str1) == normalize('NFC', str2))
def fold_equal (str_1, str_2):
    return (normalize('NFC', str_1).casefold() ==
            normalize('NFC', st_2).casefold())
```

## Memoryview

```py
# memoryview objects allow python to access the data inside the object
# without copy if it supports the buffer protocol
v = memoryview(object) # create a memoryview with reference to object
# slice of memoryview produces new memoryview

# MEMORYVIEW METHODS
v.tobytes() # return data as bytestring, equivalent to bytes (v)
v.hex() # returns string containing two hex digits for each byte in the buffer
v.tolist() # returns the data in the buffer as a list of elements
v.toreadonly()
v.release() # release the buffer below
v.cast(format, shape) # change the format or shape of the memoryview
see object # object of the memoryview
v.format # format of the memoryview
v.itemsize # size in bytes of each element of the memoryview
v.ndim # integer indicating the size of the multidimensional array represented
v.shape # tuple of integers indicating the shape of the memoryview
```

| Format String | C Type               | Python Type | Standard Size |
| ------------- | -------------------- | ----------- | ------------- |
| `x`           | `pad byte`           | `no value`  |
| `c`           | `char`               | `bytes`     | `1`           |
| `b`           | `signed char`        | `integer`   | `1`           |
| `B`           | `unsigned char`      | `integer`   | `1`           |
| `?`           | `_Bool`              | `bool`      | `1`           |
| `h`           | `short`              | `integer`   | `2`           |
| `H`           | `unsigned short`     | `integer`   | `2`           |
| `i`           | `int`                | `integer`   | `4`           |
| `I`           | `unsigned int`       | `integer`   | `4`           |
| `l`           | `long`               | `integer`   | `4`           |
| `L`           | `unsigned long`      | `integer`   | `4`           |
| `q`           | `long long`          | `integer`   | `8`           |
| `Q`           | `unsigned long long` | `integer`   | `8`           |
| `n`           | `ssize_t`            | `integer`   |
| `N`           | `size_t`             | `integer`   |
| `f`           | `float`              | `float`     | `4`           |
| `F`           | `double`             | `float`     | `8`           |
| `s`           | `char[]`             | `bytes`     |
| `P`           | `char[]`             | `bytes`     |

## Dictionaries

```py
# SET OF KEY-VALUE PAIRS
d = {1: 'Alex', 2: 'Bob', 3: 'Carl'}
d = dict (one = 'Alex', two = 'Bob', three = 'Carl')
d = dict (zip ([1,2,3], ['Alex', 'Bob', 'Carl']))
d = dict ([(1, 'Alex'), (2, 'Bob'), (3, 'Carl')])

d[key] # returns value associated with key
d[4] = 'Dan' # add or change element
list(d) # returns a list of all elements
len(d) # returns the number of elements
del(d[2]) # delete element

# DICTIONARY METHODS
d.clear() # remove all elements
d.copy() # shallow copy of the dictionary
d.get(key) # returns the value associated with key
d.items() # return key-value pairs (view object)
d.keys() # return dictionary keys (view object)
d.values​​() # returns dictionary values ​​(view object)
d.pop(key) # remove and return the value associated with key
d.popitem() # remove and return the last key-value pair
d.setdefault(key, default) # if the key is present in the dictionary it returns it, otherwise it inserts it with the default value and returns default

d.update(iterable) # add or modify dictionary elements, argument must be key-value pair

# DICT UNION
d = {'spam': 1, 'eggs': 2, 'cheese': 3}
e = {'cheese': 'cheddar', 'aardvark': 'Ethel'}

d | e # {'spam': 1, 'eggs': 2, 'cheese': 'cheddar', 'aardvark': 'Ethel'}
e | d # {'aardvark': 'Ethel', 'spam': 1, 'eggs': 2, 'cheese': 3}
d |= e # {'spam': 1, 'eggs': 2, 'cheese': 'cheddar', 'aardvark': 'Ethel'}

# NESTED DICTIONARIES (it is possible to nest dictionaries within dictionaries)
my_dict = {'key_1': 123, 'key_2': [12, 23, 33], 'key_3': ['item_0', 'item_1', 'item_2']}
my_dict ['key'][0] # returns nested element

# DICT COMPREHENSIONS
var = {key: value for element in sequence}
```

## Operators

### Mathematical Operators

| Operator | Operation                      |
| -------- | ------------------------------ |
| x `+` y  | addition, string concatenation |
| x `-` y  | subtraction                    |
| x `*` y  | multiplication                 |
| x `*+` y | exponentiation                 |
| x `/` y  | division (result always float) |
| x `//` y | integer division               |
| x `%` y  | modulo, remainder              |

### Relational Operators

| Operator | Operation           |
| -------- | ------------------- |
| x `<` y  | less than           |
| x `<=` y | less or equal to    |
| x `>` y  | greater than        |
| x `>=` y | greater or equal to |
| x `==` y | equality            |
| x `!=` y | inequality          |

### Assignment

| Operator  | Operation  |
| --------- | ---------- |
| x `+=` y  | x = x + y  |
| x `-=` y  | x = x - y  |
| x `*=` y  | x = x \* y |
| x `/=` y  | x = x / y  |
| x `//=` y | x = x // y |
| x `%=` y  | x = x % y  |
| x `<<=` y | x = x << y |
| x `>>=` y | x = x >> y |
| x `&=` y  | x = x & y  |
| x `       | =` y       | x = x | y |
| x `^=` y  | x = x ^ y  |

### Bitwise Operators

| Operator | Operation       |
| -------- | --------------- |
| `~`x     | bitwise NOT     |
| x `&` y  | bitwise AND     |
| x `^` y  | bitwise XOR     |
| x `|` y  | bitwise OR      |
| x `<<` y | left bit shift  |
| x `>>` y | right bit shift |

### Logical Operators

| Operator | Operation   |
| -------- | ----------- |
| `and`    | logical AND |
| `or`     | logical OR  |
| `not`    | logical NOT |

### Identity Operators

| Operator | Operation            |
| -------- | -------------------- |
| `is`     | reference equality   |
| `is not` | reference inequality |

### Membership Operators

| Operator | Operation              |
| -------- | ---------------------- |
| `in`     | item in collection     |
| `not in` | item not in collection |

### OPerator Precedence

1. assignment operators `+=`, `-=`, `*=`, `/=`, `%=`, `**=`, `//=`
2. binary arithmetic operators `*`, `/`, `%`, `//` (floor division)
3. binary arithmetic operators `+`, `-`
4. boolean operators `<`, `>`, `<=`, `>=`
5. boolean operators `==`, `!=`
6. boolean operator `and`
7. boolean operator `or`
8. boolean operator `not`

## Conditional Statements

Any object can be tested for truth value for use in an if or while condition or as operand of the Boolean operations.

built-in objects considered *false*:

- constants defined to be false: `None` and `False`.
- zero of any numeric type: `0`, `0.0`, `0j`, `Decimal(0)`, `Fraction(0, 1)`
- empty sequences and collections: `''`, `()`, `[]`, `{}`, `set()`, `range(0)`

### `if-else`

```py
if (condition):
    # code here
elif (condition):
    # code here
else:
    # code here
```

### Context Manager

```py
with resource as target:
     # code here

# start context manager and bind resource returned by method to target using as operator
contextmanager.__enter__(self)

# exit runtime context
# returns exc_type, exc_value, traceback
contextmanager.__exit__(self, exc_type, exc_value, traceback)
# exc_type: exception class
# exc_value: exception instance
# traceback: traceback object
# NO EXCEPTION -> returns None, None, None
# SUPPRESSION EXCEPTION: Must return True value
```

## Loops

### `while`

```py
while(condition):
     # code here
else:
     # executed only if condition becomes False
     # break, continue, return in block while do not perform else block
     # code here
```

### `for`

```py
for index in sequence: # sequence can be a list, set, tuple, etc ..
     # code here
else:
     # executed only if for reaches the end of the loop
     # break, continue, return in block for do not perform else block
     # code here

for index in range (start, end, step):
     # code here

for key, value in dict.items ():
     # code here
```

### `break` & `continue`

`break`: causes the loop to exit immediately without executing subsequent iterations
`continue`: skip the remaining iteration statements and continue the loop

### `range`

```py
range(start, end, step) # generate sequence num integers (does not include num stops) with possible step
list(range(start, end, step)) # return sequence of integers in a list
```

### `enumerate`

```py
enumerate(iterable) # iterable of item & index pairs
list(enumerate(iterable)) # returns list of tuples [(1, iterable [0]), (2, iterable [1]), (3, iterable [2])]
```

### `zip`

```py
list_1 = [1, 2, 3, 4, 5]
list_2 = ['a', 'b', 'c', 'd', 'e']

zip(list_1, list_2) # return zip object
list(zip(list_1, list_2)) # returns list of tuples by merging list [(list_1 [0], list_2 [0]), (list_1 [1], list_2 [1]), ...]
```

### `shuffle` & `randint`

```py
from random import shuffle, randint
shuffle(iterable) # shuffle the list
randint(start, end) # returns a random integer between start and end
```

### `in`

```py
item in iterable # check for the presence of item in iterable (returns True or False)
```

## Functions

### Function Definition

```py
def function_name (parameters):
     "" "DOCSTRING" ""
     # code here
     return expression # if return id missing the function returns None
```

### Specify Type Parameters In Functions

- parameters before `/` can only be *positional*
- parameters between `/` and `*` can be *positional* or *keyworded*
- parameters after `*` can only be *keyworded*
  
```py
def func (a, b, /, c, d, *, e, f):
     # code here
```

### Docstring Style

```py
"""function description

Args:
     argument: Type - description of the parameter

Returns:
     Type - description of <expr>

Raises:
     Exception: Cause of the exception
"""
```

### *args **kwargs

`*args` allows the function to accept a variable number of parameters (parameters stored in a tuple)
`**kwargs` allows the function to accept a variable number of key-value parameters (parameters stored in a dictionary)

When used in combination `*args` always goes before`**kwargs` (in def function and in function call)

```py
def func(*args, **kwargs):
    # code here
```

### Function with default parameters

```py
def function(parameter1 = value1, parameter2 = value3): # default values in case of omitted use of arguments in the call
     # code here
     return expression

function(parameter2 = value2, parameter1 = value1) # arguments passed with keyword to enforce the order of reference
```

### Global And Local Variables

```py
# global scope

def external_func():
     # enclosing local scope

     def internal_func():
         # local scope
```

**LEGB Rule**:

- **L** - **Local**: Names assigned in any way within a function (`def` or `lambda`), and not declared global in that function.
- **E** - **Enclosing function locals**: Names in the local scope of any and all enclosing functions (`def` or `lambda`), from inner to outer.
- **G** - **Global** (module): Names assigned at the top-level of a module file, or declared global in a def within the file.
- **B** - **Built-in** (Python): Names preassigned in the built-in names module : `open`, `range`, `SyntaxError`,...

`Note`: variables declared inside a function are not usable outside

```py
def function():
     # global statement makes a variable global
     # actions on global variable within the function also have an effect outside

     global variable
```

### Iterables, Iterators & Generators

**Iterable**: object implementing `__iter __()`, sequences and objects supporting `__getitem__` with index `0`

**Iterator**: object implementing `__next__` and `__iter__` (**iterator protocol**), when entirely consumed by `next()` it becomes unusable. Returns `StopIteration` when `next()` has returned all elements.

**Generator Function**: function with keyword `yield` (if present also `return` causes `StopIteration`), returns a generator that produces the values ​​one at a time.

**Generator Factory**: generator returning function (may not contain `yield`).

Operation `iter()`:

- calls `__iter__()`
- in the absence of it python uses `__getitem__()` (if present) to create an iterator that tries to retrieve the items in order, starting from the index `0`
- on failure it returns `TypeError`
  
**Note**: `abc.Iterable` does not check for the presence of `__getitem__` to decide if a sub-object is a member therefore the best test for iterability is to use `iter()` and handle exceptions.

### `next()` & `iter()`

```py
next(iterable) # next item of the iterable or error StopIteration

iter(object) # get an iterator from an object
# call callable_onj.next () with no arguments as long as it returns non-sentinel values

iter(callable_obj, sentinel)
```

### Customs Generators

Used to generate a sequence of values to be used once (they are not stored)

```py
def custom_generator(parameters):
     while condition: # or for loop
         yield variable # returns the value without terminating the function, values passed to the caller without storing in a variable

# generator implementation
for item in custom_generator(parameters):
     # code here
```

### Termination Generator And Exception Handling

```py
# raise exception at the suspension point and return generator value
# if the generator terminates without returning values it raises StopIteration
# if an exception is not handled it is propagated to the caller
generator.throw(ExceptionType, exception_value, traceback)

# raises GeneratorExit to the point of suspension
# if generator returns a value -> RuntimeError
# if an exception is raised it propagates to the caller
generator.close()
```

### Generator Comprehensions

```py
# zero-length sequence (instantaneously generated values)
var = (for expression iterable in sequence if condition)
# EDUCATION ENUMERATE ()
# returns a list of tuples associating a position index to each element of the sequence
# [(0, sequence [0]), (1, sequence [1]), (2, sequence [2]), ...)
enumerate(sequence) # -> enumerate object
```

## Coroutines

```py
def simple_coroutine():
    """coroutine defined as a generator: yield in block"""

    # yield in expression to receive data
    # returns None (no variables on the right of yield)
    var = yield value # returns value and then suspends coroutine waiting for input
    # instructions to the right of = executed before instructions to the left of =

gen_obj = simple_coroutine() # returns generator object
next(gen_obj) # start coroutine (PRIMING)
gen_obj.send(None) # start coroutine (PRIMING)
gen_obj.send(value) # send value to the coroutine (only possible in suspended state)

# STATES OF COROUTINE
inspect.generatorstate() # returns the status of the coroutine
# GEN_CREATED: waiting to start execution
# GEN_RUNNING: currently run by the interpreter (visible if multithreaded)
# GEN_SUSPENDED: currently suspended by yield statement
# GEN_CLOSED: execution completed successfully

# COROUTINE PRIMING
from functools import wraps

def coroutine(func):
    "Decorator: primes 'func' by advancing to first 'yield'"

    @wraps(func)
    def primer(*args, **kwargs):
        gen = func(*args, **kwargs)
        next(gen)
        return gen
    return primer

# COROUTINE TERMINATION AND EXCEPTION HANDLING
# exceptions in unhandled coroutines propagate to subsequent iterations
# an exception causes the coroutine to terminate which it cannot resume

# yield raises exception, if handled loop continues
# throw() returns value of the generator
coroutine.throw(exc_type, exc_value, traceback)

# yield raises GeneratorExit to the suspension point
# if the generator yields a value -> RuntimeError
# if there are other exceptions they are propagated to the caller
coroutine.close()
# coroutine state becomes GEN_CLOSED
```

### `yield from <iterabile>`

**Note**: auto-priming generators incompatible with `yield from`

**DELEGATING GENERATOR**: generator function containing `yield from`
**SUBGENERATOR**: generator obtained from `yield from`
**CALLER-CLIENT**: code calling *delegating generator*

The main function of `yield from` is to open a bidirectional channel between the external caller (* client *) and the internal * subgenerator * so that values and exceptions can pass between the two.

1. client calls delegating generator, delegating generator calls subgenerator
2. exhausted subgenerator returns value to `yield from <expr>` (`return <result>` statement)
3. delegating generator returns `<expr>` to client

- Any values that the subgenerator yields are passed directly to the caller of the delegating generator (i.e., the client code).

- Any values sent to the delegating generator using `send()` are passed directly to the subgenerator.
  - If the sent value is `None`, the subgenerator's `__next__()` method is called.
  - If the sent value is not `None`, the subgenerator's `send()` method is called.
  - If the call raises `StopIteration`, the delegating generator is resumed.
  - Any other exception is propagated to the delegating generator.

- `return <expr>` in a generator (or subgenerator) causes `StopIteration(<expr>)` to be raised upon exit from the generator.

- The value of the `yield from` expression is the first argument to the `StopIteration` exception raised by the subgenerator when it terminates.

- Exceptions other than `GeneratorExit` thrown into the delegating generator are passed to the `throw()` method of the subgenerator.
  - If the call raises `StopIteration`, the delegating generator is resumed.
  - Any other exception is propagated to the delegating generator.

- If a `GeneratorExit` exception is thrown into the delegating generator, or the `close()` method of the delegating generator is called, then the `close()` method of the subgenerator is called if it has one.
  - If this call results in an exception, it is propagated to the delegating generator.
  - Otherwise, `GeneratorExit` is raised in the delegating generator

```py
def sub_gen():
     sent_input = yield
     # result of sub_gen() returned to delegating_gen()
     # result of yield from <expr>

     return result

def delegating_gen(var):
     var = yield from sub_gen() # get values from sub_gen

def client():
     result = delegating_gen() # use delegating_gen
     result.send(None) # terminate sub_gen instance (IMPORTANT)
```

## LAMBDA Functions

Possible use within functions. Useful for replacing functions if the logic is simple.

```py
var = lambda argument_list: <expression>
```

## Object Oriented Programming

### Class Definition

```py
class Class:

    static_var = expression

    def __init__(self, value_1, value_2): # parameterized default constructor
        self.variable = value_1 # create instance variables
        self.__private = value_2 # private, accessed via NAME MANGLING

    def method(self, parameters):
        ...

    @staticmethod
    def static_method(parameters): # static methods do not affect instance variables (SELF not needed)
        ...

    @classmethod # method acting on the class and not on the object (useful for alternative constructors)
    def class_method(cls, parameters):
        ...

    object = Class(parameters) # creation of an object
    object.variable = expression # edit public variable
    object.method(parameters) # invocation method of instance
    object._Class__private # access to variable specifying the membership class (NAME MANGLING)
    Class.method(parameters) # static method invocation
```

### Setter & Getter with `@Property`

```py
class Class:
     def __init__(self, parameter):
         self.__parameter = parameter

     @property # getter
     def parameter(self):
         return self.__parameter

     @<parameter>.setter
     def parameter(self, value):
         self.__parameter = value
```

### `__slots__`

The `__slots__` attribute implements the **Flyweight Design Pattern**: it saves the instance attributes in a tuple and can be used to decrease the cost in memory by inserting only the instance variables into it (suppress the instance dictionary).

**Default**: attributes saved in a dictionary (`object .__ dict__`)
**Usage**: `__slots_ = [attributes]`

`__slots__` is not inherited by subclasses, it prevents dynamically adding attributes.

### Inner Classes

```py
class Class:
     def __init__(self, parameters):
         ...

     class InnerClass:
         def __init__(self, parameters):
             ...

         def method(self):
             ...

object_1 = Class(arguments) # create 'external' class
object_2 = Class.InnerClass(arguments) # inner class created as object of the 'external' class
```

### Special Methods

Special methods are defined by the use of double underscores; they allow the use of specific functions (possibly adapted) on the objects defined by the class.

```py
class Class():

     def __init__(self, parameters):
         instructions

     # used by str() and print() method
     # handle requests for impersonation as a string
     def __str__ (self):
         return expression # return required

     def __len__ (self):
         return expression # must return as len requires a length / size

     def __del__ (self): # delete the class instance
         instruction # any instructions that occur on deletion

object = Class()
len(object) # special function applied to an object
del object # delete object
```

#### Special Methods List

**Note**: if the operator cannot be applied, returns `NotImplemented`

```py
# arithmetic operators
__add__(self, other)         # +
__sub__(self, other)         # -
__mul__(self, other)         # *
__matmul__(self, other)      # (@) matrix multiplication
__truediv__(self, other)     # /
__floordiv__(self, other)  # //
__mod__(self, other)         # %
__divmod__(self, other)      # divmod()
__pow__(self, other)         # **, pow()
__lshift__(self, other)      # <<
__rshift__(self, other)      # >>
__and__(self, other)         # &
__xor__(self, other)         # ^
__or__(self, other)          # |

# reflex arithmetic operators
# if self.__ dunder __(other) fails, other.__ dunder__(self) is called
__radd__(self, other)         # reverse +
__rsub__(self, other)         # reverse -
__rmul__(self, other)         # reverse *
__rmatmul__(self, other)      # reverse @
__rtruediv__(self, other)     # reverse /
__rfloordiv__(self, other)    # reverse //
__rmod__(self, other)         # reverse %
__rdivmod__(self, other)      # reverse divmod()
__rpow__(self, other)         # reverse **, pow()
__rlshift__(self, other)      # reverse <<
__rrshift__(self, other)      # reverse >>
__rand__(self, other)         # reverse &
__rxor__(self, other)         # reverse ^
__ror__(self, other)          # reverse |

# in-place arithmetic operators
# base implementation (built-in) like self = self <operator> other
#! not to be implemented for immutable objects!
#! in-place operators return self!
__iadd__(self, other)         # +=
__isub__(self, other)         # -=
__imul__(self, other)         # *=
__imatmul__(self, other)      # @=
__itruediv__(self, other)     # /=
__ifloordiv__(self, other)    # //=
__imod__(self, other)         # %=
__ipow__(self, other)         # **=
__ilshift__(self, other)      # <<=
__irshift__(self, other)      # >>=
__iand__(self, other)         # &=
__ixor__(self, other)         # ^=
__ior__(self, other)          # |=

# unary mathematical operators (-, +, abs (), ~)
__neg__(self)  # (-) negazione matematica unaria [if x = 2 then -x = 2]
__pos__(self)  # (+) addizione unaria [x = +x]
__abs__(self)  # [abs()] valore assoluto [|-x| = x]
__invert__(self)  # (~) inversione binaria di un intero [~x == -(x + 1)]

# numeric type conversion
__complex__(self)
__int__(self)  # if not defined fall-back on __trunc__()
__float__(self)
__index__(self)  # conversion in bin(), hex(), oct() e slicing

# operations round() math.trunc(), math.floor(), math.ceil()
__round__(self)
__trunc__(self)
__floor__(self)
__ceil__(self)

# equality operators
self.__eq__(other)  # self == other
self.__ne__(other) # self != other
self.__gt__(other) # self > other
self.__ge__(other) # self >= other
self.__lt__(other) # self < other
self.__le__(other) # self <= other

# reflected equality operators
other.__eq__(self)  # other == self,   fall-back id(self) == id(other)
other.__ne__(self)  # other != self,   fall-back not (self == other)
other.__gt__(self)  # reverse self < other,   fall-back TypeError
other.__ge__(self)  # reverse self <= other,   fall-back TypeError
other.__lt__(self)  # reverse self > other,   fall-back TypeError
other.__le__(self)  # reverse self >= other,   fall-back TypeError

# called when the instance is "called" as a function
# x (arg1, arg2, arg3) is short for x .__ call __ (arg1, arg2, arg3)
__call__(self, args)

# string object representation for the developer
__repr__(self)

# string object representation for user (used by print)
__str__(self)

# specify formatting for format ), str.format() [format_spec = format-mini-language]
__format__(format_spec)

# returns unique (integer) value for objects that have equal value
# __EQ__ MUST EXIST IN THE CLASS, usually hash((self.param_1, self.param_2, ...))
__hash__(self)

# makes object iterable:
# - returning self (in the iterator)
# - returning an iterator (in the iterable)
# - using yield (in the __iter__ generator)
__iter__(self)

# returns next available element, StopIteration otherwise (iterator scrolls)
__next__()

# returns truth value
__bool__()

# returns item associated with key of a sequence (self [key])
# IndexError if key is not appropriate
__getitem__(self, key)

# item assignment operation in sequence (self [key] = value)
# IndexError if key is not appropriate
__setitem__(self, key, value)

# operation deleting item in sequence (del self [key])
# IndexError if key is not appropriate
__delitem__(self, key)

# called by dict.__getitem__() to implement self [key] if key is not in the dictionary
__missing__(self, key)

# implement container iteration
__iter__(self)

# implement membership test
__contains__(self, item)

# implementation issublass (instance, class)
__instancecheck__(self, instance)

# implementation issubclass (subclass, class)
__subclasscheck__(self, subclass)

# implement attribute access (obj.name)
# called if AttributeError happens or if called by __getattribute __()
__getattr__(self, name)

# implement value assignment to attribute (obj.name = value)
__setattr__(self, name, value)
```

**Note**: Itearbility is tricky.

To make an object directly iterable (`for i in object`) `__iter__()` and `__next__()` are needed.
To make an iterable through an index (`for i in range(len(object)): object[i]`) `__getitem()__` is needed.

Some of the mixin methods, such as `__iter__()`, `__reversed__()` and `index()`, make repeated calls to the underlying `__getitem__()` method.
Consequently, if `__getitem__()` is implemented with constant access speed, the mixin methods will have linear performance;
however, if the underlying method is linear (as it would be with a linked list), the mixins will have quadratic performance and will likely need to be overridden.

### Inheritance

```py
class Parent ():
    def __init __ (self, parameters):
        ...

    def method_1(self):
        ...

    def method_2(self):
        ...

class Child(Parent): # parent class in parentheses to inherit variables and methods

    def __init__(self, parameters, parent_parameters):
        Parent.__init__(self, parent_parameters) # inherit parent variables
        ...

    def method (self):
        ...

    def method_parent_1 (self): # override method (child class with homonymous method to parent class)
        ...

class Child(Parent): # parent class in brackets to inherit properties

    def __init__(self, parameters, parent_parameters):
        super().__init__(parent_parameters) # different method to inherit parent variables (SELF not needed) using SUPER()
        super(Parent, self).__init__(parent_parameters) # parent constructor invoked separately

    def method(self):
        ...

    def method_2(self): # parent method updated
        super().method_2() # invoke parent method as is
        ...
```

### Polymorphism

**Note**: python does not support method overloading

```py
# DUCKTYPING
# Working with objects regardless of their type, as long as they implement certain protocols

class Class1:
    def method_1(self):
        ...

class Class2:
    def method_1(self):
        ...

# since python is a dynamic language it doesn't matter what type (class) the object passed is
# the function invokes the object method passed regardless of the object class
def polymorph_method(object):
    object.method_1()

# DEPENDENCY INJECTION WITH DUCKTYPING
class Class:
    def __init__(self, object):
        self.dependency = object

    def method_1(self): # the function invokes the method of the object passed
        self.dependency.method_1()
```

### Operator Overloading

**Operators fundamental rule**: *always* return an object, if operation fails return `NotImplemented`

Limitations of operator overloading:

- no overloading of built-in types
- no creation of new operators
- no overloading operators `is`, `and`, `or`, `not`

### Astrazione

The **interfaces** are abstract classes with *all* abstract methods, they are used to indicate which methods such as child classes *must* have. Interfaces have *only* a list of abstract methods.

**abstract classes** have *at least* one abstract method; child classes that inherit from an abstract class *must* implement abstract methods. Abstract classes *cannot* be instantiated.

Virtual subclasses are used to include third-party classes as subclasses of a class of their own. They are recognized as belonging to the parent class without however having to implement their methods.

The `@Class.register` or `Class.register(subclass)` decorators are used to mark subclasses.

```py
from abc import abstractmethod, ABC

class Abstract(ABC): # abstract class MUST INHERIT from parent class ABC
    def __init__(self, parameters):
        ...

    def parent_method (self):
        ...

    @abstractmethod # abstract method MUST be marked with @abstractmethod decorator
    def abstract_method (self):
        pass
        # abstract method MUST be overridden (can be non-empty)
        # super() to invoke it in the concrete class

class Child(Abstract):

    def __init__(self, parameters, parent_parameters):
        parent_class.__init__(self, parent_parameters)

    def method (self):
        ...

    def parent_method (self): # override method (child class with homonymous method to parent class)
        ...

    def abstract_method (self): # implementation of abstract method inherited from abstract class (NECESSARY) by override
        ...
```

## Exception Handling

```py
# CHECK ASERATIONS
assert condition, 'error message' # if the assertion is false show an error message

# particular errors are objects of a particular class of exceptions which in turn is a child of the base exception class (exception)
class CustomExceptionError(Exception): # MUST somehow inherit from class exception (even in later inheritance steps)
    pass # or instructions

# try block contains code that might cause an exception
# code inside try and after the error it is not executed
try:
    ...
    raise CustomExceptionError ("message") # raise the exception

# except takes control of error handling without passing through the interpreter
# block executed if an error occurs in try

# except error specified by class
except ExceptionClass:
    # Default error message is not shown
    # the program does not stop

# except on generic errors
except:
     # code here

# block executed if exception does not occur
else:
    # code here

# block executed in all cases, cleanup code goes here
finally:
    # code here
```

## File

### Opening A File

Text file opening mode:

- `w`: write, overwrite the contents of the file
- `r`: read, read file contents
- `a`: append, add content to the file
- `w +`: write & read
- `r +`: write & read & append
- `a +`: append & read
- `x`: exclusive creation, if the file already exists -> `FileExistError` (extended write mode)

Open binary file mode:

- `wb`: write, overwrites the contents of the file
- `rb`: read, read file contents
- `ab`: append, add content to the file
- `w + b`: write & read
- `r + b`: write & read & append
- `a + b`: append & read
- `xb`: exclusive creation, if the file already exists -> `FileExistError` (extended write mode)

**Note**: Linux and MacOSX use `UTF-8` everywhere while windows uses `cp1252`, `cp850`,`mbcs`, `UTF-8`. Don't rely on default encoding and use **explicitly** `UTF-8`.

```py
object = open('filename', mode = 'r', encoding = 'utf-8') # encoding MUST BE utf-8 for compatibility
# filename can be the absolute path to the file location (default: file created in the source code folder)
# double slash to avoid \ escaping

with open('filename') as file:
    instructions_to_file # block use filename to indicate file

# CLOSE A FILE
object.close()

# WRITE TO A FILE
object.write(string) # write single string to file
object.writelines(* strings) # write multiple strings to file

# READING FROM A FILE
object.read() # return ALL the contents of the file (including escape sequence) and place the "cursor" at the end of the file
object.seek(0) # returns 0 (zero) and places the cursor at the beginning of the file
object.readlines() # return list of file lines (ATTENTION: keep everything in memory, be careful with large files)
object.readline() # returns single line file

# CHECK FILE EXISTENCE
import os, sys
if os.path.isfile('filepath'): # check file existence (TRUE if it exists)
    # code here
else:
    # code here
    sys.exit() # exits the program and does not execute the next cosice
```

## COPY

**SHALLOW COPY**: copies the "container" and references to the content
**DEEP COPY**: copies the "container" and contents (no reference)

```py
copy (x) # returns shallow copy of xor
deepcopy (x) # returns shallow copy of x
```
