Лекция 2
Tinkoff Python
Декораторы, структура проекта, Автотесты
План
- Работа с файлами и Pathlib
- Декораторы
- Модули и пакеты
- Управление зависимостями
- Собственный пакет
- Авто-тесты
- Структура проекта
- Линтеры
Работа с файлами
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
- Все в одном месте
- Удобный ООП стиль
- Переносимость между разными ОС
Pathlib заменит вам
- open
- os.mkdir
- os.rmdir
- os.path.join
- os.path.*
- glob
Декораторы
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/9362222/pasted-from-clipboard.png)
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
Конфигурируемый декоратор
Модули и пакеты
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/9362130/pasted-from-clipboard.png)
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) ...
Проблема решается
- Перепланировкой модулей
- Переносом импорта внутрь функции/кода, в которой он необходим
Управление зависимостями
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/9362263/pasted-from-clipboard.png)
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
Почему ставить все пакеты глобально — плохая идея?
- Зависимости могут конфликтовать
- Могут появиться неявные зависимости
- Сложно воспроизвести окружение
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"
...
Решает проблему
- с несовместимыми версиями пакетов
- с фиксацией транзитивных зависимостей
- упрощает многие операции
Собственный пакет
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%
Автотесты
Зачем?
![](https://hsto.org/webt/c5/oa/ze/c5oaze8gmau8ticskgu44o5wyza.jpeg)
Зачем?
- Проверяют корректность и делают это быстро
- Баги всплывают раньше (bug cost)
- Изменения в код вносить проще (feature cost)
Тесты - тоже код!
![](https://s3.amazonaws.com/media-p.slid.es/uploads/1116156/images/8182532/pasted-from-clipboard.png)
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
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5724079/pasted-from-clipboard.png)
Покрытие кода тестами
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
```
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5720104/pasted-from-clipboard.png)
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