Лекция 2

Tinkoff Python

Декораторы, структура проекта,  Автотесты

План

  1. Работа с файлами и Pathlib
  2. Декораторы
  3. Модули и пакеты
  4. Управление зависимостями
  5. Собственный пакет
  6. Авто-тесты
  7. Структура проекта
  8. Линтеры

Работа с файлами

import os

directory = '/home/user/temp/'
filepath = os.path.join(directory, 'data.csv')

if os.path.exists(filepath):
    with open(filepath, "r") as file:
        print(f.read())

Pathlib

from pathlib import Path

directory = Path('/home/user/temp/')
filepath = directory / 'data.csv'

if filepath.exists():
    with filepath.open() as file:
        print(file.read())

Преимущества Pathlib

  1. Все в одном месте
  2. Удобный ООП стиль
  3. Переносимость между разными ОС

 

Pathlib заменит вам

  • open
  • os.mkdir
  • os.rmdir
  • os.path.join
  • os.path.*
  • glob

Декораторы

Callable object

Объект, который можно вызвать через ()

Можно проверить по наличию магического метода __call__

или через вызов функции callable(v) => bool

@my_decorator
def some_func():
  ...
  
  
some_func()

Как выглядит в python

def some_func():
  ...
  
  
some_func = my_decorator(some_func)
some_func()

Синтаксический сахар

def div(func):
  def wrapper():
    return f'<div>{func()}</div>'
  return wrapper


@div
def hello():
  return 'Hello'
  

Пример использования

In [11]: hello()
Out[11]: '<div>Hello</div>'
@div
@p
def hello():
  return 'Hello'
  

Последовательность декораторов

In [5]: hello()
Out[5]: '<div><p>Hello</p></div>'
def my_decorator(func):
  ...

Как написать свой декоратор

def my_decorator(func):
  ...
  return wrapper  # callable object

Как написать свой декоратор

def my_decorator(func):
  
  def wrapper():
    # ...
    result = func()
    # ... 
    
    return result
  
  return wrapper

Как написать свой декоратор

def my_decorator(func):
  
  def wrapper(*args, **kwargs):
    # ...
    result = func(*args, **kwargs)
    # ... 
    
    return result
  
  return wrapper

*args, **kwargs

def time_it(func):
  def wrapper(*args, **kwargs):
    start_time = time()
    result = func(*args, **kwargs)
    print(func.__name__, time() - start_time)
    return result
  return wrapper


@time_it
def square(x):
  sleep(0.1)
  return x*x

Пример декоратора

In [15]: square(2)
square 0.100001
Out[15]: 4
@my_decorator
def some_func():
  ...
  
  
print(some_func.__name__)  # wrapper

Есть нюанс...

from functools import wraps


def my_decorator(func):
  
  @wraps(func)
  def wrapper(*args, **kwargs):
    # ...
    result = func(*args, **kwargs)
    # ... 
    
    return result
  
  return wrapper

from functools import wraps

@my_decorator
def some_func():
  ...
  
  
print(some_func.__name__)  # some_func

Нет проблемы, если использовать wraps

@logtime(0.2)
def square(x):
  sleep(0.1)
  return x*x
  
  

Конфигурируемый декоратор

In [6]: square(2)
Out[6]: 4

Конфигурируемый декоратор

def square(x):
  sleep(0.1)
  return x*x
  
  
square = logtime(0.2)(square)
square(10)
In [6]: square(10)
Out[6]: 100
def logtime(max_time=0.1):
  def decorator(func):

    def wrapper(*args, **kwargs):
      st = time()
      
      result = func(*args, **kwargs)
      
      executed_time = time() - st
      if executed_time >= max_time:
        print(...)

      return result

    return wrapper
  
  return decorator

Конфигурируемый декоратор

Модули и пакеты

Modules

$ ls
foo.py
bar.py

https://docs.python.org/3/tutorial/modules.html

def hello(name):
  print(f'Hello from {name}')

hello('cat.py')
$ python cat.py

Hello from cat.py

Типичный модуль

cat.py <
from cat import hello

hello('dog.py')
$ python dog.py

Hello from cat.py
Hello from dog.py

Импорт модулей

cat.py
dog.py <
from cat import *

hello('dog.py')
$ python dog.py

Hello from cat.py
Hello from dog.py

Импорт модулей

cat.py
dog.py <
import cat
import cat

...

Импорт модулей

cat.py
dog.py <

Код импортируемого модуля выполнится,

но только один раз!

(во время первого импорта)

__name__

встроенная переменная, возвращающая имя текущего исполняемого модуля

"__main__"

значение имени модуля в том случае, если модуль исполняется в качестве основной программы

def hello(name):
  print(f'Hello from {name}')

if __name__ == '__main__':
  hello('cat.py')
$ python cat.py

Hello from cat.py
cat.py <
dog.py

if __name__ == "__main__": ...

Packages

from animals.cat import hello

hello('dog.py')
$ python dog.py

Hello from dog.py
animals/
  __init__.py
  cat.py
dog.py <

Типичный пакет

__init__.py

  • интерпретатор рассматривает каталоги, содержащие __init__.py, как пакеты с модулями

 

  • первый файл, загружаемый в модуль:
    • для выполнения определенного кода каждый раз при загрузке модуля;
    • для указания (содержащихся в пакете) модулей для экспорта.
from .cat import hello

hello('animals.__init__')
$ python dog.py

Hello from animals.__init__
Hello from dog.py
animals/
  __init__.py <
  cat.py
dog.py

__init__.py

from animals import hello

hello('dog.py')
$ python dog.py

Hello from animals.__init__
Hello dog.py
animals/
  __init__.py
  cat.py
dog.py <

Структура пакета скрыта в __init__.py

А что, если мы хотим запустить пакет как скрипт?

from .cat import hello

if __name__ == '__main__':
  hello('animals.__main__')
$ python -m animals

Hello from animals.__init__
Hello from animals.__main__
animals/
  __init__.py
  __main__.py <
  cat.py
dog.py

__main__.py

Типы импортов

from .cat import hello - относительный

from animals.cat import hello - абсолютный (рекомендуем)

Где python ищет код, когда мы что-то импортируем?

PYTHONPATH

$ python -c "import sys; print(sys.path)"
['', ... '.../python3.7/site-packages']

Кольцевые импорты

# cat.py
from dog import hello_dog

def hello_cat():
    print('hello_cat')

hello_dog()
# dog.py
from cat import hello_cat

def hello_dog():
    print('hello_dog')

hello_cat()
$ python dog.py

ImportError: cannot import name ...
  (most likely due to a circular import) ...

Проблема решается

  • Перепланировкой модулей
  • Переносом импорта внутрь функции/кода, в которой он необходим

Управление зависимостями

PIP - package manager

https://pip.pypa.io/en/stable/

pip install <package>

requirements.txt

$ cat requirements.txt
pytest==4.1.1
flake8==3.7
pylint==2.2

# установка всех пакетов из requirements.txt
$ pip install -r requirements.txt

Фиксация зависимостей

requirements.txt

$ pip freeze > requirements.txt

$ cat requirements.txt
tomicwrites==1.2.1
attrs==18.2.0
more-itertools==5.0.0
pluggy==0.8.1
py==1.7.0
pytest==4.1.1
six==1.12.0

# установка всех пакетов из requirements.txt
$ pip instal -r requirements.txt

Зависимости можно разделять

# requirements-dev.txt
-r requirements.txt
pytest==4.1.1
flake8==3.7
pylint==2.2

Почему ставить все пакеты глобально — плохая идея?

  1. Зависимости могут конфликтовать
  2. Могут появиться неявные зависимости
  3. Сложно воспроизвести окружение

venv

$ python -m venv .venv

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

venv

.venv/
  bin/
    ...
    activate
    python
    python3.10
  lib/
    python3.10/site-packages/
      attr/
      pip/
      ...
$ source .venv/bin/activate

$ pip list
Package    Version
---------- -------
pip        20.0.2
setuptools 41.2.0

Активация venv

$ deactivate

$ pip list
Package                      Version
---------------------------- ----------
apns                         2.0.1
asn1crypto                   0.24.0
...

Как управлять зависимостями?

Poetry

https://python-poetry.org/docs/

Poetry

$ pip install poetry

Установка:

$ poetry add pydantic
$ poetry remove pydantic
$ poetry add --dev pytest
$ poetry remove --dev pytest
$ poetry update pydantic

Управление пакетами:

pyproject.toml

[tool.poetry]
name = "myproject"
version = "0.0.1"

[tool.poetry.dependencies]
python = "^3.8"
sqlalchemy = "^1.3.3"
psycopg2-binary = "^2.8"
...

Решает проблему

  1. с несовместимыми версиями пакетов
  2. с фиксацией транзитивных зависимостей
  3. упрощает многие операции

 

Собственный пакет

setup.py

import setuptools

with open("README.md", "r") as fh:
    long_description = fh.read()

setuptools.setup(
    name="example-pkg-your-username",
    version="0.0.1",
    author="Example Author",
    author_email="author@example.com",
    description="A small example package",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="https://github.com/pypa/sampleproject",
    packages=setuptools.find_packages(),
)

https://packaging.python.org/tutorials/packaging-projects/

Имея setup.py, мы можем

# установить пакет 
# с указанием пути 
# до директории с setup.py
$ pip install .

Имея setup.py, мы можем

# установить пакет 
# из внешней vcs, 
# например с github
$ pip install \
-e git+https://git.repo/some_pkg.git#egg=SomeProject

Имея setup.py, мы можем

# собираем архив с пакетом
$ python setup.py sdist bdist_wheel

twine

# ставим дополнительную утилиту для сборки
$ pip install twine
...

$ twine upload dist/*  # заливаем пакет в реестр
Uploading distributions to https://pypi.org/
Enter your username: [your username]
Enter your password:
Uploading example_pkg_your_username-0.0.1-py3-none-any.whl
100%|█████████████████████| 4.65k/4.65k [00:01<00:00, 2.88kB/s]
Uploading example_pkg_your_username-0.0.1.tar.gz
100%|█████████████████████| 4.25k/4.25k [00:01<00:00, 3.05kB/s]

Poetry

(снова)

pyproject.toml

[tool.poetry]
name = "overhave"
version = "1.1.2"
description = "Overhave - web-framework for BDD"
readme = "README.rst"
authors = [
    "Vladislav Mukhamatnurov <livestreamepidemz@yandex.ru>",
    "Tinkoff Dialog System Backend Team <bds-dev@tinkoff.ru>"
]
classifiers = [
    "Development Status :: 5 - Production/Stable",
    "License :: OSI Approved :: Apache Software License",
    "Intended Audience :: Developers",
    "Topic :: Software Development :: Libraries :: Application Frameworks",
    "Topic :: Software Development :: Testing",
    "Topic :: Software Development :: Testing :: BDD",
    "Programming Language :: Python :: 3.7",
    "Programming Language :: Python :: 3.8",
    "Operating System :: OS Independent",
    "Framework :: Flask",
    "Framework :: Pytest",
]
...

Публикация пакета

% poetry config pypi-token.pypi $PYPI_TOKEN
$ poetry publish --build

Building overhave (1.1.2)
  - Building sdist
  - Built overhave-1.1.2.tar.gz
  - Building wheel
  - Built overhave-1.1.2-py3-none-any.whl

Publishing overhave (1.1.2) to PyPI
 - Uploading overhave-1.1.2-py3-none-any.whl 100%
 - Uploading overhave-1.1.2.tar.gz 100%

Автотесты

Зачем?

Зачем?

  • Проверяют корректность и делают это быстро
  • Баги всплывают раньше (bug cost)
  • Изменения в код вносить проще (feature cost)

Тесты - тоже код!

assert

def square(x):
    assert isinstance(x, int), 'type error'
    if __debug__:
        print('debug')
    return x * x

Unittest

# tests/test_utils.py
import unittest

def square(x):
    return x * x

class SquareTestCase(unittest.TestCase):

    def test_square_ok(self):
        self.assertEqual(square(3), 9)

    def test_square_error(self):
        self.assertEqual(square(3), 8)
$ python -m unittest
F.
======================================================================
FAIL: test_square_error (tests.test_utils.SquareTetsCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./tests/test_utils.py", line 14, in test_square_error
    self.assertEqual(square(3), 8)
AssertionError: 9 != 8

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

Pytest

Меньше шаблонного кода

# tests/test_utils.py
import unittest

def square(x):
    return x * x

class SquareTestCases(unittest.TestCase):

    def test_square_ok(self):
        self.assertEqual(square(3), 9)

    def test_square_error(self):
        self.assertEqual(square(3), 8)
# tests/test_utils.py

def square(x):
    return x * x


def test_square_ok():
    assert square(3) == 9


def test_square_error():
    assert square(3) == 8

Удобные assert`ы

# tests/test_utils.py
import unittest


class TestCase(unittest.TestCase):

    def test_dict_equal(self):
        self.assertDictEqual({'x': 1}, {'x': 2})
# tests/test_utils.py

def test_dict_equal():
    assert {'x': 1} == {'x': 2}

Удобные assert`ы

# tests/test_utils.py

def test_dict_equal():
    assert {'x': 1, 'y': 3, 'z': 0} == {'x': 2, 'y': 3}
    def test_dict_equal():
>       assert {'x': 1, 'y': 3, 'z': 0} == {'x': 2, 'y': 3}
E       AssertionError: assert {'x': 1, 'y': 3, 'z': 0} == {'x': 2, 'y': 3}
E         Omitting 1 identical items, use -vv to show
E         Differing items:
E         {'x': 1} != {'x': 2}
E         Left contains more items:
E         {'z': 0}
E         Use -v to get the full diff

tests/test_utils.py:4: AssertionError

А так же

  • фикстуры
  • плагины
  • параметризованные тесты
  • etc.

Структура типичного теста

  • Подготовка (опционально)
  • Действие
  • Проверка
  • Завершение (опционально)

Начнем с начала

def square(x):
    return x * x

def test_square():
    assert square(2) == 4
    assert square(-2) == 4
    assert square(0) == 0
$ pytest
==================== test session starts ===================
platform darwin -- Python 3.7.2, pytest-4.1.1, py-1.7.0, pluggy-0.8.1      
rootdir: ., inifile:
collected 1 items

tests/test_utils.py ....                                           [100%]
==================== 1 passed in 0.05 seconds ==============

Много маленьких тестов лучше одного большого

(тест-дизайн, классы эквивалентности)

Начнем с начала

def square(x):
    return x * x

def test_square_int():
    assert square(2) == 4

def test_square_zero():
    assert square(0) == 0

def test_square_negative_number():
    assert square(-2) == 4
$ pytest
==================== test session starts ===================
platform darwin -- Python 3.7.2, pytest-4.1.1, py-1.7.0, pluggy-0.8.1      
rootdir: ., inifile:
collected 3 items

tests/test_utils.py ....                                           [100%]
==================== 3 passed in 0.05 seconds ==============

Изменим реализацию

def square(x):
    return x ** 2  # тут!

def test_square_int():
    assert square(2) == 4

def test_square_zero():
    assert square(0) == 0

def test_square_negative_number():
    assert square(-2) == 4
$ pytest
==================== test session starts ===================
platform darwin -- Python 3.7.2, pytest-4.1.1, py-1.7.0, pluggy-0.8.1
rootdir: ., inifile:
collected 3 items

tests/test_utils.py ....                                         [100%]
==================== 3 passed in 0.05 seconds ==============

Параметризованные тесты

import pytest

def square(x):
    return x ** 2

@pytest.mark.parametrize(('number', 'result'), [
    (2, 4),
    (0, 0),
    (-2, 4),
])
def test_square(number, result):
    assert square(number) == result
$ pytest -v
==================== test session starts ===================
platform darwin -- Python 3.7.2, pytest-4.1.1, py-1.7.0, pluggy-0.8.1 -- ./.venv/bin/python3
cachedir: .pytest_cache
rootdir: ., inifile:
collected 3 items

tests/test_utils.py::test_square[2-4] PASSED                                        [ 25%]
tests/test_utils.py::test_square[0-0] PASSED                                        [ 75%]
tests/test_utils.py::test_square[-2-4] PASSED

==================== 3 passed in 0.05 seconds ==============

Тесты являются контрактами того, как работает наш код

Проверка исключений

import pytest

def square(x):
    return x ** 2

def test_square_not_number():
    with pytest.raises(TypeError):
        square('string')

Подготовка данных

class User:
  ...

  
def test_user_hello():
  user = User('Vasya', 20)
  assert user.hello() == 'hello'

    
def test_user_bye():
  user = User('Vasya', 20)
  assert user.bye() == 'bye'

Фикстуры

class User:
    ...

    
@pytest.fixture()
def user():
    return User('Vasya', 20)

  
def test_user_hello(user):
    assert user.hello() == 'hello'

    
def test_user_bye(user):
    assert user.bye() == 'bye'

Fixture scope

@pytest.fixture(scope='session')
def user():
    return User('Vasya', 20)

Фикстуры для фикстур

@pytest.fixture(scope='session')
def parent():
    return User('Petya', 40)

@pytest.fixture()
def user(parent):
    return User('Vasya', 20, parent)

Before/After test actions

import pytest

@pytest.fixture()
def prepare_env():
    # our before test actions
    yield
    # our after test actions

    
def test_env(prepare_env):
    # our test
    pass

Before/After test actions

import pytest

@pytest.fixture()
def prepare_env():
    init_db()
    yield
    clean_db()

    
def test_env(prepare_env):
    # our test
    pass

autouse

import pytest

@pytest.fixture(autouse=True)
def prepare_env():
    # our before test actions
    yield
    # our after test actions

def test_env():
    # our test
    pass

Mock

from unittest.mock import MagicMock

def test_with_mock():
    # preparing
    thing = ProductionClass()
    thing.method = MagicMock(return_value=3)

    # action
    thing.method(3, 4, 5, key='value')
    
    # asserts
    thing.method.assert_called_with(3, 4, 5, key='value')

Зачем?

  • Подменяем реальные объекты
  • Задаем нужные нам возвращаемые значения/исключения
  • Проверяем вызывался ли нужный метод/функция на самом деле

pytest-mock

import os

def test_unix_fs(mocker):
    mocker.patch('os.remove')
    os.remove('file')
    os.remove.assert_called_once_with('file')

Пример

@pytest.fixture()
def user(mocker):
    mock = mocker.MagicMock()
    mock.hello.return_value = 'hello'
    mock.bye.return_value = 'bye'
    mock.wrong.side_effect = ValueError('error')
    return mock

spec

@pytest.fixture()
def user(mocker):
    return mocker.MagicMock(spec=User)

autospec

@pytest.fixture()
def mock_user(mocker):
    mock.patch("app.models.User", autospec=True)

mocker.patch

import requests
import pytest


def get_page():
    return requests.get('www.google.com')

  
@pytest.fixture()
def requests_mock(mocker):
    return mocker.patch('requests.get')

  
def test_get_page(requests_mock):
    requests_mock.return_value.json.return_value = {'id': 1}
    response = get_page()
    assert response.json() == {'id': 1}

mocker.patch.object

def test_mocked_user_1(mocker, user):
    mock = mocker.patch.object(user, 'bye')
    mock.return_value = 'hello'
    assert user.bye() == 'hello'

    
def test_mocked_user_2(mocker, user):
    mocker.patch.object(user, 'bye', return_value='hello')
    assert user.bye() == 'hello'

    
def test_mocked_user_3(mocker, user):
    mocker.patch.object(user, 'bye').return_value = 'hello'
    assert user.bye() == 'hello'

conftest.py

Покрытие кода тестами

Pytest-cov

$ pytest --cov=app tests/
...


-------------------- coverage: ... ---------------------
Name                 Stmts   Miss  Cover
----------------------------------------
app/__init__          2      0     100%
app/__main__          257    13    94%
app/utils             94     7     92%
----------------------------------------
TOTAL                 353    20    94%

Можно явно отметить код, который не хотим покрывать

class MyObject(object):
    def __init__(self):
        blah1()
        blah2()

    def __repr__(self): # pragma: no cover
        return "<MyObject>"
  • __repr__
  • __str__
  • __main__.py - если в нем тривиальный код вызова другого модуля
  • В других случаях нужно помечать комментарием, почему этот код не тестируется

Debugger

breakpoint etc.

Debugger

def test_dict_equal():
    ... # many code lines
    breakpoint()
    assert {'x': 1, 'y': 3, 'z': 0} == {'x': 2, 'y': 3}
>>>>>>> PDB set_trace (IO-capturing turned off) >>>>>>>>>>>>>
> ./tests/test_utils.py(6)test_dict_equal()
-> assert {'x': 1, 'y': 3, 'z': 0} == {'x': 2, 'y': 3}
(Pdb) h

Documented commands (type help <topic>):
========================================
EOF    cl         display  interact  n     restart  step       up
a      clear      down     j         next  return   tbreak     w
...

Debugger

$ pytest --pdb tests

Test-Driven Development

Структура проекта

README.md

# My project

Descriptions bla-bla-bla...

## Run tests

```
make test
```

Makefile

Makefile

PYTHONPATH = PYTHONPATH=./

TEST = $(PYTHONPATH) pytest --verbosity=2 ... $(arg)
CODE = tests app

test:
  $(TEST) --cov

test-failed:
  $(TEST) --last-failed

.gitignore

Типичный проект

app/
   __init__.py
   __main__.py
   utils.py
   ...
tests/
   conftest.py
   test_utils.py
   ...
docs/
   conf.py
   index.rst
   ...
 .gitignore
 Makefile
 poetry.lock
 pyproject.toml
 README.md

Автоформаттеры и линтеры

isort

https://isort.readthedocs.io/en/latest/

isort

isort --apply --recursive my_package

isort

from my_lib import my_func
import os, sys
from third_party import lib2
from third_party import lib1

print("my beautiful code")
import os  # встроенные пакеты
import sys

from third_party import lib1, lib2  # внешние пакеты

from my_lib import my_func  # локальные пакеты

print("my beautiful code")

before

after

flake8

$ flake8 --statistics my_package.py

my_package.py:4:12: E211 whitespace before '('
def logtime (max_time  =0.1):
           ^
my_package.py:4:22: E251 unexpected spaces around keyword / parameter equals
def logtime (max_time  =0.1):
                     ^
my_package.py:5:23: E202 whitespace before ')'
    def decorator(func ) :
                      ^
my_package.py:5:25: E203 whitespace before ':'
    def decorator(func ) :
                        ^
my_package.py:7:21: E201 whitespace after '('
        def wrapper( *args, ** kwargs):
                    ^
my_package.py:8:15: E225 missing whitespace around operator
            st=time()
              ^
my_package.py:11:13: E303 too many blank lines (2)
            result = func(*args, **kwargs)

https://flake8.pycqa.org/en/latest/

pylint

$ pylint my_package.py

************* Module my_package
my_package.py:1:0: C0114: Missing module docstring (missing-module-docstring)
my_package.py:4:0: C0116: Missing function or method docstring (missing-function-docstring)
my_package.py:8:12: C0103: Variable name "st" doesn't conform to snake_case naming style (invalid-name)

-----------------------------------
Your code has been rated at 7.50/10

https://pypi.org/project/pylint/

FinTech 2022. Python 2

By Afonasev Evgeniy

FinTech 2022. Python 2

Работа с файлами Модули и пакеты Управление зависимостями Собственный пакет Декораторы, генераторы Авто-тесты Автоформаттеры и линтеры Структура проекта

  • 329