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)
Не нужно писать то, что уже есть в языке
Как будем сдавать ДЗ
- Создаем репозиторий с названием task-N , где N номер лекции, после которой дано задание
- Пишем код
- заливаем его в отдельную ветку (не master)
- Делаем PullRequest из своей ветки в master
- Исправляем замечания проверяющих
Если 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
- статический анализ кода
- автоформатирование
Что получим в итоге?
- Освежим в памяти основы работы с модулями/пакетами
- Научимся писать автотесты
- Познакомимся с утилитами, облегчающими рутинные задачи
- Договоримся о структуре наших проектов
На чем проверялся код
- 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 мы можем
Перерыв?
Автотесты
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5761875/pasted-from-clipboard.png)
Зачем?
- Проверяют корректность и делают это быстро
- Баги всплывают раньше (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
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5724079/pasted-from-clipboard.png)
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.
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5777647/pasted-from-clipboard.png)
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
Статический анализ кода
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5777653/pasted-from-clipboard.png)
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
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5720104/pasted-from-clipboard.png)
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
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5776934/pasted-from-clipboard.png)
makefile aliases
ДЗ
- Оформить проект в соответствии с предложенной структурой
- Прогнать код через автоформатирование
- Привести код в состояние, когда он проходит проверки линтеров
- Покрыть код автотестами со 100% покрытием
Tinkoff-Python-2
By Afonasev Evgeniy
Tinkoff-Python-2
- 489