Tinkoff Python

Episode 2

Структура проекта, пакетирование, автотесты, полезные инструменты

В предыдущей серии...

Замечания по ДЗ

Не нужно добавлять в VCS

  • .idea (.vscode)
  • venv
  • python cache
  • etc.

.gitignore

__pycache__/
*.py[cod]

.idea
.vscode
.python-version
*.iml
.env
.cache
.pytest_cache/
/docker-compose.override.yml
venv
.venv
dist/
.coverage
htmlcov/
.tox
lines = []

for line in f.readlines():
    if line not in lines:
        lines.append()

if item in list

items = []  # какой-то список

if len(items) > 0:
    ...

if len(items) == 0:
    ...

if len(items):
    ...

if not len(items):
    ...

if len(items) > 0

items = []  # какой-то список

if items:
    ...

if not items:
    ...


# magic methods __bool__ or __len__

if len(items) > 0

file = open(filename)
try:
    ...
finally:
    file.close()

open(file)

with open(filename) as f:
    ...

open(file)

from contextlib import ExitStack

with ExitStack() as stack:
    files = [
        stack.enter_context(open(fname)) 
        for fname in filenames
    ]
    ...

open(file)

Не нужно писать то, что уже есть в языке

Как будем сдавать ДЗ

  1. Создаем репозиторий с названием task-N , где N номер лекции, после которой дано задание
  2. Пишем  код
  3. заливаем его в отдельную ветку (не master)
  4. Делаем PullRequest из своей ветки в master
  5. Исправляем замечания проверяющих

Если PR смержен проверяющим, задача принята.

Pathlib

from pathlib import Path

path = Path('./foo/bar')
path = path / 'dog' / 'cat'
path.mkdir(parent=True)

file_path = path / 'file.txt'

with file_path.open() as f:
    f.write('body')

Pathlib

Pathlib заменит вам

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

О чем будем говорить?

  • Модули, пакеты кода на python
  • Управление зависимостями
  • Написание автотестов
  • code debug
  • статический анализ кода
  • автоформатирование

Что получим в итоге?

  1. Освежим в памяти основы работы с модулями/пакетами 
  2. Научимся писать автотесты
  3. Познакомимся с утилитами, облегчающими рутинные задачи
  4. Договоримся о структуре наших проектов

На чем проверялся код

  • python3.7
  • macOS Mojave 10.14.2

Modules

$ ls
foo.py
bar.py

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

# cat.py

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

hello(__name__)
$ python cat.py
Hello __main__
cat.py

Импорт модуля

# dog.py
from cat import hello

hello(__name__)
$ python3 dog.py
Hello cat
Hello __main__
cat.py
dog.py

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

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

Как этого избежать?

if __name__ == "__main__": ...

# cat.py

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

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

Packages

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

# dog.py
from animals.cat import hello

hello(__name__)
$ python dog.py
Hello __main__
animals
 - __init__.py
 - cat.py
dog.py

__init__.py

# animals/__init__.py
from .cat import hello

hello(__name__)
$ python dog.py
Hello animals
Hello __main__
animals
 - __init__.py
 - cat.py
dog.py

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

# dog.py
from lib import hello

hello(__name__)
$ python dog.py
Hello animals
Hello __main__
animals
 - __init__.py
 - cat.py
dog.py

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

__main__.py

# animals/__main__.py
from .cat import hello

if __name__ == '__main__':
    hello(__name__)
$ python -m animals
Hello animals
Hello __main__
animals
 - __init__.py
 - __main__.py
 - cat.py
dog.py

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

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

from animals.cat import hello - абсолютный

Относительные импорты могут иметь глубокий уровень вложенности

например: from ...cat import hello если бы cat.py лежал во вложенном пакете

Где 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()

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

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

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

Package != Package

PIP - package manager

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

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

  • Конфликты версий
  • Невоспроизводимость окружения
$ python -m venv .venv

$ source .venv/bin/activate

$ pip list
Package    Version
---------- -------
pip        18.1
setuptools 40.6.2

$ deactivate

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

Venv

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

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

requirements.txt

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

$ source .venv/bin/activate

$ pip instal pytest

$ 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 --requirement requirements.txt

requirements.txt

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

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

Как сделать свой пакет?

setup.py

# 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(),
)
# установить пакет с указанием пути до директории с setup.py
$ pip install .
...

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

# залить пакет на PyPI и устанавливать как обычно
$ python setup.py sdist bdist_wheel  # собираем архимв с пакетом
...

$ 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]

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

Перерыв?

Автотесты

Зачем?

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

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

assert

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

Unittests

# tests/test_utils.py
import unittest

def square(x):
    return x * x

class SquareTetsCase(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

def square(x):
    return x * x


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


def test_square_error():
    assert square(3) == 8
# tests/test_utils.py
import unittest

def square(x):
    return x * x

class SquareTetsCase(unittest.TestCase):

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

    def test_square_error(self):
        self.assertEqual(square(3), 8)

VS

Удобные assert`ы

# tests/test_utils.py
import unittest


class TetsCase(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}

vs

Удобные 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_int():
    assert square(2) == 4

def test_square_float():
    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 4 items

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

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

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

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

def test_square_float():
    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 4 items

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

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

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

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

import pytest

def square(x):
    return x ** 2

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

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

import pytest

def square(x):
    return x ** 2

@pytest.mark.parametrize(('number', 'result'), [
    (2, 4),
    (2.0, 4.0),
    (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 4 items

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

==================== 4 passed in 0.05 seconds ==============

Добавим проверку

import math

import pytest

def square(x):
    return x ** 2

@pytest.mark.parametrize(('number', 'result'), [
    (2, 4),
    (2.0, 4.0),
    (0, 0),
    (-2, 4),
    (math.inf, math.inf)
])
def test_square(number, result):
    assert square(number) == result

Фикстуры

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

@pytest.mark.usefixtures('prepare_env')
def test_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

class UnixFS:
    @staticmethod
    def rm(filename):
        os.remove(filename)

def test_unix_fs(mocker):
    mocker.patch('os.remove')
    UnixFS.rm('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'
    return mock

spec

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

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

Code coverage

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%

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

PEPs

PEP8

Статический анализ кода

Flake8

$ flake8 path/to/code/
app/__init__.py:1:1: W391 blank line at end of file
app/utils.py:7:2: E225 missing whitespace around operator
app/utils.py:10:1: E402 module level import not at top of file
app/utils.py:10:1: F401 'os' imported but unused
app/__main__.py:1:1: F401 '.utils' imported but unused

Flake8 plugins

  • flake8-builtins
  • flake8-comprehensions
  • flake8-eradicate
  • flake8-isort
  • flake8-logging-format
  • flake8-pytest
  • pep8-naming
  • etc.

Pylint

$ pylint path/to/code
************* Module app.utils
app/utils.py:7:1: C0326: Exactly one space required around assignment
x=2
 ^ (bad-whitespace)
app/utils.py:1:0: C0111: Missing module docstring (missing-docstring)
app/utils.py:5:0: C0103: Constant name "x" doesn't conform to UPPER_CASE naming style (invalid-name)
app/utils.py:7:0: C0103: Constant name "x" doesn't conform to UPPER_CASE naming style (invalid-name)
app/utils.py:10:0: C0413: Import "import os" should be placed at the top of the module (wrong-import-position)
app/utils.py:10:0: W0611: Unused import os (unused-import)
# my_strange_module.py
# pylint:disable=missing-docstring
import os # noqa: F401

Отключение проверок

Настройки линтеров

[flake8]
enable-extensions = G
exclude = .git
ignore =
    A003 ; 'id' is a python builtin, consider renaming the class attribute
    W504 ; Line break occurred after a binary operator
max-complexity = 20
max-line-length = 80
show-source = true

[pylint]  ; `pylint --rcfile=setup.cfg my_code`
good-names=i,j,k,e,x,_,pk,id
max-module-lines=300
output-format = colorized

disable=
    C0103, ; Constant name "api" doesn't conform to UPPER_CASE naming style (invalid-name)
    C0111, ; Missing module docstring (missing-docstring)
    C0330, ; Wrong hanging indentation before block (add 4 spaces)
    E0213, ; Method should have "self" as first argument (no-self-argument) - N805 for flake8
    R0201, ; Method could be a function (no-self-use)
    R0901, ; Too many ancestors (m/n) (too-many-ancestors)
    R0903, ; Too few public methods (m/n) (too-few-public-methods)
    W0511, ; TODO needed? (fixme)

setup.cfg

Что еще?

  • Vulture
  • Dead
  • Mypy (flake8-mypy)
  • etc.

Почему линтить стиль кода не эффективно?

Автоформатирование кода

Black

# BEFORE
x = {  'a':37,'b':42,

'c':927}

y = 'hello ''world'
z = 'hello '+'world'
a = 'hello {}'.format('world')
class foo  (     object  ):
  def f    (self   ):
    return       37*-+2
  def g(self, x,y=42):
      return y
def f  (   a ) :
  return      37+-+a[42-x :  y**3]
# AFTER
x = {"a": 37, "b": 42, "c": 927}

y = "hello " "world"
z = "hello " + "world"
a = "hello {}".format("world")


class foo(object):
    def f(self):
        return 37 * -+2

    def g(self, x, y=42):
        return y


def f(a):
    return 37 + -+a[42 - x : y ** 3]
$ black --py36 app
...

vs

Isort

$ isort --apply --recursive app
...
from my_lib import Object

print("Hey")

import os

from my_lib import Object3

from my_lib import Object2

import sys

from third_party import libA
import sys

from third_party import libB

print("yo")
import os
import sys

from third_party import libA, libB

from my_lib import Object, Object2, Object3

print("Hey")
print("yo")

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

# My project

Descriptions bla-bla-bla...

### Run tests

```
pytest code
```

### Run linters

```
flake8 code
```

### Run autoformat

```
isort --apply --recursive code
black --py36 code
```

README.md

app/
 - __init__.py
 - utils.py
 - ...
test/
 - conftest.py
 - test_utils.py
 - ...
setup.cfg
requirements.txt # or setup.py
README.md
.gitignore

В итоге типичный проект будет выглядеть как-то так

Cookiecutter

$ cookiecutter gl:tinkoff-python-spring-2019/public/cookiecutter-pyproject

makefile aliases

ДЗ

  1. Оформить проект в соответствии с предложенной структурой
  2. Прогнать код через автоформатирование
  3. Привести код в состояние, когда он проходит проверки линтеров
  4. Покрыть код автотестами со 100% покрытием

Tinkoff-Python-2

By Afonasev Evgeniy

Tinkoff-Python-2

  • 489