Introduction to Python

Data Engineering @ Statnett


Simeon Simeonov

Goals


  • Present the Python programming language in a different way than https://docs.python.org

  • Avoid information overload

  • Use examples and interaction rather than documents and slides

Preliminary plan


  • Basics: About the language, the Python eco-system, types, modules, functions, scopes, decorators, string formatting

  • Object-oriented programming in Python: How Python "really works"

  • Control flow: if / for / while / try, iterators, "tactical programming" tips

  • A brief tour through Python's standard library

  • Code design and best practices: How to design your code

What is Python?


  • Python is an interpreted high-level general-purpose programming language - advanced through the Python Enhancement Proposal (PEP) process

  • CPython is the reference implementation of Python, written in C (alternatives: pypy, jython)

  • python - interpreter and interpreter shell (alternatives: ipython, bpython)

  • libpython

  • Calling C from Python: Cython, CFFI, ctypes

Philosophy


            
              $ python
            
          
            
              import this

              # The Zen of Python, by Tim Peters

              # Beautiful is better than ugly.
              # Explicit is better than implicit.
              # Simple is better than complex.
              # Complex is better than complicated.
              # Flat is better than nested.
              # Sparse is better than dense.
              # Readability counts.
              # Special cases aren't special enough to break the rules.
              # Although practicality beats purity.
              # Errors should never pass silently.
              # Unless explicitly silenced.
              # In the face of ambiguity, refuse the temptation to guess.
              # There should be one-- and preferably only one --obvious way to do it.
              # Although that way may not be obvious at first unless you're Dutch.
              # Now is better than never.
              # Although never is often better than *right* now.
              # If the implementation is hard to explain, it's a bad idea.
              # If the implementation is easy to explain, it may be a good idea.
              # Namespaces are one honking great idea -- let's do more of those!
            
          

Built-in functions


Few built-in functions.

https://docs.python.org/3/library/functions.html

  • dir([obj]) - returns a list of valid attributes for that object
  • id(obj) - returns the "identity" of an object - an integer which is guaranteed to be unique
  • print(...) - prints objects to a text stream
  • str(...) - returns a string version of object
  • type(obj) - returns the type of an object

Common built-in types


Python uses duck typing and has typed objects but untyped variable names.

Type constraints are not checked at compile time; rather, operations on an object may fail, signifying that the given object is not of a suitable type. Despite being dynamically-typed, Python is strongly-typed, forbidding operations that are not well-defined (for example, adding a number to a string) rather than silently attempting to make sense of them.

            
              s = 'foo'  # this is a string / str, same as str('foo'), may be encoded, immutable
              b = b'foo'  # bytes, same as bytes('foo', 'utf-8'), may be decoded, immutable
              i = 6  # int, same as int('6'), immutable
              f = 0.1  # float, same as float('0.1'), immutable
              b = False  # bool, same as bool(0), bool(''), bool(None)... immutable / constant
              n = None  # NoneType, similar to 'null' in other languages, immutable / constant
              l = [1, False, 'foo']  # list, same as list((1, False, 'foo'))
              t = (1, False, 'foo')  # tuple, same as tuple([1, False, 'foo']), immutable
              d = {'foo': 1, 'bar': 8}  # dict, same as dict(foo=1, bar=8), similar to hash in other languages
              s = {'foo', 'bar', 1, 1, 4}  # set, same as set(['foo', 'bar', 1, 1, 4]), removes duplicates
            
          

Classes, functions, modules, .... and even types are simply other types :)

Modules


A module is a file containing Python definitions and statements. The file name is the module name with the suffix .py appended. Within a module, the module's name (as a string) is available as the value of the global variable __name__

When a module named foo is imported, the interpreter first searches for a built-in module with that name (sys.builtin_module_names). If not found, it then searches for a file named foo.py in a list of directories given by the variable sys.path. sys.path is initialized from these locations:

  • the directory containing the input script (or the current directory when no file is specified)
  • PYTHONPATH - env. variable - a list of directory names
  • the installation-dependent default locations

The module is then imported only once and "cached" in sys.modules

Packages


Packages are a way of structuring Python's module namespace by using "dotted module names"

The import statement combines two operations:

  • it searches for the named module
  • it binds the results of that search to a name in the local scope
            
              # bar.py then bar/__init__.py will be considered, the first match executed and bound to 'bar'
              import bar

              import mymodule.foo  # implicitly executes mymodule.py, mymodule/__init__.py and mymodule/foo/__init__.py

              import numpy as np  # will be bound as 'np' instead of 'numpy'. N.B. __name__ is still 'numpy'

              import some.extremely.deep.path.Animal as Animal  # "sacrifice" the namespace in the name of convinience

              from sys import path  # execute sys and only import the 'path' attribute into local scope as 'path'

              # relative imports must be explicit in Python 3
              from .othermodule import something  # expects that current module and 'othermodule' are in the same
                                                  # package (containing __init__.py)

              from sys import *  # NO! Bad programming practice since 1879
            
          

Creating and maintaining a Python environment


Python's official package repository is PyPi (https://pypi.org), while Python's official package installer is pip (https://pypi.org/project/pip/)

A Python environment is the physical and logical arrangement of Python modules and packages. Several options exist:

  • using a proper operating system :) (symlinks, real commercial support etc.)
  • using venv
  • using higher level tools like poetry
  • using a mixture / cocktail of all of the above :)

Creating and maintaining a Python environment (cont...)


Desired qualities for a flexible Python environment:

  • easy to create and (un)load
  • do not require extra privileges
  • don't repeat yourself (DRY)
  • easy to update without breaking the API
  • easy to debug
  • play nicely with the VCS (git)

Creating and maintaining a Python environment (cont...)


Exploting the operating system can be done by:

  • (re)defining PYTHONPATH
  • using symlinks to point at packages placed at different locations

Creating and maintaining a Python environment (cont...)


Using venv can be done by directly invoking python:

            
              # create a virtual environment
              python -m venv my_virtual_env
              python -m venv --system-site-packages my_virtual_env

              # load, use and unload the virtual environment
              source my_virtual_env/bin/activate
              pip install sqlalchemy
              # install package from a custom repository (https://artifactory.fifty.eu)
              pip install --index-url=https://artifactory.fifty.eu/artifactory/api/pypi/pypi/simple/ odin-data-access
              deactivate

              # one can alternatively use the python "wrapper" of the virtual env
              my_virtual_env/bin/python -m pip install sqlalchemy
            
          

--system-site-packages will keep the original site-packages folders at the end of sys.path

Creating and maintaining a Python environment (cont...)


Poetry (https://python-poetry.org) is the prefered environment and dependency management tool at Statnett.

            
              # create project and a virtual environment from scratch
              poetry new my-project

              # ... or use Poetry with an existing one
              cd my-project
              poetry init

              # edit pyproject.toml for your needs (f.i. add dependencies, metadata ... etc),
              # create virtual environment and install dependencies
              poetry install
              # finally commit your poetry.lock file to version control

              # update all dependencies
              poetry update
            
          

For more info: https://python-poetry.org/docs/basic-usage/

Mutables vs. immutables

Immutable object is an object with a fixed value. Immutable objects include bool, int, float, str, bytes and tuples. Such an object cannot be altered. A new object has to be created if a different value has to be stored. They play an important role in places where a constant hash value is needed, for example as a key in a dictionary.

All objects that are not immutable are... mutable. All hashable objects should be immutable or use id().

            
              i = 1
              id(i)  # returns f.i. 9788992
              i += 1  # same as i = i + 1
              id(i)  # returns a different value, hence - a brand new object

              s = 'Hello'
              s += ' World'  # s is now a different object
              s[0]  # 'H'
              s[0] = 'h'  # TypeError: 'str' object does not support item assignment

              t = (1, 4)  # tuple
              l = [1, 4]  # list
              hash(t)  # returns f.i. -6333845781340707986
              hash(l)  # TypeError: unhashable type: 'list'

              s = 'the long and winding road'
              s2 = 'the long and winding road'

              # check if s and s2 are the same object:
              id(s)  # Out: 139858905258704
              id(s2)  # Out: 139858926037472

              # the hash should be the same
              hash(s)  # Out: 7030216208569256362
              hash(s2)  # Out: 7030216208569256362
            
          

Functions


A function is a sequence of program instructions that performs a specific task, packaged as a unit.

Functions let you:

  • reuse code across several programs / projects
  • minimize code duplication
  • devide larger programming tasks
  • hide implementation details
  • improve readability
  • improve traceability

Function calls bring some overhead pushing / popping function-data into / from stack.

Important definitions (may have different meanings in different programming languages):

  • parameter / formal parameter - variable / data provided as input to the function
  • argument / actual parameter - local variable / data to the given function

The keyword def introduces a function definition.

Functions (cont...)


            
              def add(a, b):  # - function definition / header
                  """Function for adding integers"""  # - docstring
                  result = a + b
                  a = 5
                  return result  # - function that does not contain return, implicitly returns None

              a_param = 9
              b_param = -2
              add(a_param, b_param)  # Out: 7

              # integers are immutable and a_param will remain unchanged
              print(a_param)  # Out: 9


              def addl(a, b):
                  """Function for adding two lists"""
                  result = a + b
                  a += [5]  # in this case equal to: a.append(5)
                  return result

              a_param = [9]
              b_param = [-2]
              addl(a_param, b_param)  # Out: [9, -2]
              # lists are mutable and a_param will be changed
              print(a_param)  # Out: [9, 5]
            
          

Functions (cont ...)


Parameters and arguments

            
              def add(a, b):
                  """Function for adding integers"""
                  return a + b
              my_result = add(2, 5)  # positional arguments (parameters)
              my_result = add(b=5, a=2)  # keyword arguments (parameters)
              my_tuple = (2, 5)
              my_dict = {'b': 5, 'a': 2}
              my_result = add(*my_tuple)  # unpacked and assigned to the positional arguments
              my_result = add(**my_dict)  # unpacked and assigned to the kw. arguments

              def add(a, b=5):
                  """Function for adding integers"""
                  return a + b
              my_result = add(2)
              # ... and the rest of the examples above will work

              def add(a, *args, **kwargs):
                  """Function for adding integers"""
                  if args:
                      b = args[0]
                  elif 'b' in kwargs:
                      b = kwargs['b']
                  return a + b
              my_result = add(2, 5, 9, 11)  # 5 assigned to args[0]
              my_result = add(2, b=5, c=9, d=11)  # 5 assigned to kwargs['b']
              my_result = add(2, b=5, 9, 11)  # SyntaxError: positional argument follows keyword argument
            
          

Functions (cont ...)


Docstrings annotations and other hints

            
              def decrypt(password: str, edata: str) -> str:
                  """
                  Decrypts `edata` using `password`.

                  `edata` is in the following format:
                  enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data`

                  :param password: The password to generate the key with
                  :type password: str

                  :param edata: The data to be decrypted
                  :type edata: str

                  :raises EtoolkitInstanceError: If the encryption format is unsupported

                  :return: The output string / decrypted data
                  :rtype: str
                  """
                  if not edata.startswith('enc-val$1$'):
                      raise EtoolkitInstanceError('Unsupported encryption format')
                  # some more code magic coming after....
                  # ...
                  # ..
                  return decrypted_str
            
          

Functions (cont ...)


Using typing for more advanced / flexible hinting

            
              import typing

              Basestring = typing.Union[str, bytes]

              def decrypt(password: Basestring, edata: str) -> str:
                  pass


              from typing import Union

              def decrypt(password: Union[str, bytes], edata: str) -> str:
                  """Generic documentation. No need for pass"""

              # Python >= 3.10 only
              def decrypt(password: str | bytes, edata: str) -> str:
                  """Generic documentation. No need for pass"""
            
          

Functions (cont ...)

Functions as parameters / arguments, lambdas and returning multiple values

            
              def fetch_the_first_letter(input_str: str) -> str:
                  """Fetches the first letter of the string input_str or 'x'""""
                  try:
                      return input_str[0]
                  except Exception:
                      return 'x'
              letter_list = list(map(fetch_the_first_letter, ['foo', 'bar', 'test']))  # Out: ['f', 'b', 't']
            
          

Small anonymous functions can be created with the lambda keyword

            
              letter_list = list(map(lambda x: x[0], ['foo', 'bar', 'test']))  # Out: ['f', 'b', 't']
            
          

Functions in Python are callable objects. Callable objects can be created by defining the __call__ method. More on that later in the course...

A function can return multiple values by implicitly returning a tuple:

            
              def square_cube(x):
                  """returns x, x^2 and x^3"""
                  return x, x**2, x**3
              numbers = square_cube(5)  # Out: (5, 25, 125)
              num, sqnum, cbnum = square_cube(5)  # unpacking the tuple
            
          

Functions (cont ...)


Enclosing and nested functions

Can be used as:

  • regular functions within functions
  • dynamic function factories
            
              def get_multiplier_of(base: int) -> str:
                  """the function enclosing its nested functions"""
    
                  def multiplier_function(x):
                      """a nested function"""
                      return base * x

                  return multiplier_function

              times3 = get_multiplier_of(3)
              times5 = get_multiplier_of(5)
              print(times3(3))  # Out: 9
              print(times5(3))  # Out: 15
            
          

Scopes in Python


  • local - assigned names are local unless declared global
  • enclosed - the scope of the variable inside a function with a nested function
  • global - global for the current module
  • built-in

locals() and globals() return dicts of symbols for their respective scopes

            
              num1, num2 = 7, 8  # module globals

              def print_numbers():
                  print(num1, num2)  # OK, these are module globals
                  num3 = 100
                  print(num3)  # prints 100, num3 is in the function (local) scope
                  global num4  # assignes / references num4 to / in the global scope
                  num4 = 99
                  id = 200  # new symbol in local scope
                  # id(num4)  # will not yield the expected result (raises TypeError)

                  def print_numbers2():
                      print(num3)  # OK, enclosed scope

                  print_numbers2()  # prints 100

              print_numbers()
              # print(num3)  # Raises NameError - why?
              print(num4)  # Prints 99 - why?
              # print_numbers2()  # Raises NameError
            
          

Decorators

Decorators can be used to modify the behavior of the objects they decorate. Decorators can be implemented either by using classes or by using nested functions.

            
              def my_decorator(func):

                  def decorated():
                      print('Doing something before the decorated function')
                      retval = func()
                      print('Doing something after the decorated function')
                      return retval
                  return decorated

              def my_function():
                  print('Alice')

              my_function = my_decorator(my_function)
              my_function()
            
          

... may be dificult to read / understand, while:

            
              @my_decorator
              def my_function():
                  print('Alice')

              my_function()
            
          

... may be easier

Decorators (cont ...)


A complete example

            
              import sys
              from functools import wraps

              def requires_access(access_secret: str):

                  def api_access_decorator(f):

                      @wraps(f)
                      def decorated(*args, **kwargs):
                          if 'secret' not in kwargs:
                              sys.exit('No secret provided')
                          if not kwargs['secret'] or kwargs['secret'] != access_secret:
                              sys.exit("Secret doesn't match")
                          # return f(args[0], **kwargs)
                          return f(*args, **kwargs)

                      return decorated

                  return api_access_decorator


              @requires_access(access_secret='b28cfeaa65b73cf')
              def sensitive_function(data, **kwargs):
                  """very sensitive function"""
                  db.save(data)
            
          

String formatting


The old ways...

            
              f = 6.57865
              i = 27
              s = 'another string'


              '%s - %d - %5.2f' % (s, i, f)  # Out: 'another string - 27 -  6.58'

              # still used in:
              logger.debug("%d - %s", event.id, message)


              '{} - {} - {:5.2f}'.format(s, i, f)  # implicit
              '{0} - {1} - {2:5.2f}'.format(s, i, f)  # explicit
              '{my_str} - {i} - {fl:5.2f}'.format(my_str=s, fl=f, i=i)  # keyword
              # Out: 'another string - 27 -  6.58'


              # modern Python >= 3.6 f-strings
              f'{s} - {i} - {f:5.2f}'  # Out: 'another string - 27 -  6.58'
            
          

See https://docs.python.org/3/library/string.html#formatspec for the complete format specification