How to Set Up a Python Project For Automation and Collaboration
Статья описывает пошаговую настройку Python-проекта для автоматизации проверок и удобной командной работы. Рассматриваются pyenv для управления версиями Python, venv и pip для виртуальных окружений, pytest для юнит-тестов, Coverage.py для измерения покрытия кода, pylint для линтинга и mypy для статической проверки типов. Все команды объединяются в Makefile, чтобы запускать полный набор проверок одной командой make check. Для непрерывной интеграции используется GitHub Actions — автоматические проверки выполняются при каждом git push и pull request. По оценке автора, такой подход сэкономил его командам около 50–60% усилий на код-ревью.
Как настроить Python-проект для автоматизации и командной работы
[ engineering production python productivity ] · 20 мин. чтения
По мере роста вашего Python-проекта управлять им становится всё сложнее.
Как можно автоматизировать проверки (например, юнит-тесты, проверку типов, линтинг)? Как можно минимизировать накладные расходы на совместную работу (например, код-ревью, единообразие кода)? Как можно максимально улучшить опыт разработчика, добавляя минимум дополнительных шагов?
В этой статье я поделюсь подходом, который хорошо работает для меня. К концу у нас будет автоматизированный рабочий процесс с юнит-тестами, отчётами о покрытии, проверками линтера и проверками типов, который будет выявлять большинство ошибок и облегчать совместную работу. Этот процесс будет запускаться локально одной командой (make check) и в удалённом репозитории при каждом git push.
Установка менеджера версий Python Настройка виртуального окружения и установка пакетов Альтернатива: Docker в качестве среды разработки Создание единообразной структуры проекта Добавление базовых методов Написание юнит-тестов — наша страховочная сетка Проверка покрытия: насколько полны наши тесты? Линтинг для обеспечения единообразия (между проектами) Проверка типов для предотвращения ошибок Обёртка для удобства разработчика Автоматизация проверок при каждом git push Применяйте эти практики и извлекайте выгоду
Чтобы статья оставалась краткой и понятной, мы будем использовать один — наиболее популярный — инструмент для каждого шага. Я также предложу популярные альтернативы.
Следите за материалом вместе с сопроводительным репозиторием здесь 🌟.
(Примечание: эти шаги должны работать на современных Mac или Linux. Для Windows потребуется включить подсистему Linux.)
Установка менеджера версий Python
Для начала нам понадобится менеджер версий Python. Он позволяет устанавливать и управлять несколькими версиями Python на локальной машине. Стандарт де-факто — pyenv. Вот как его установить через pyenv-installer.
# Установка pyenv curl https://pyenv.run | bash # Перезапуск оболочки (чтобы изменения PATH для pyenv вступили в силу) exec $SHELL
Теперь мы готовы устанавливать и переключаться между версиями Python. (Примечание: pyenv добавляет директорию с shim-файлами в ваш PATH. Подробнее об этом здесь.)
# Это системный python $ python --version Python 2.7.16 # Установка Python 3.8.2 $ pyenv install 3.8.2 python-build: use [email protected] from homebrew python-build: use readline from homebrew Downloading Python-3.8.2.tar.xz... -> https://www.python.org/ftp/python/3.8.2/Python-3.8.2.tar.xz Installing Python-3.8.2... python-build: use readline from homebrew python-build: use zlib from xcode sdk Installed Python-3.8.2 to /Users/eugene/.pyenv/versions/3.8.2 # Устанавливаем локальную версию python $ pyenv local 3.8.2 $ python --version Python 3.8.2 # Проверяем различные версии python $ pyenv versions system 3.7.7 3.8.0 * 3.8.2 (set by /Users/eugene/projects/python-collab-template/.python-version)
Python 3.8.2 будет версией по умолчанию для этого проекта. То есть, когда вы находитесь в этой директории, вызов python будет использовать Python 3.8.2.
При обновлении версий Python — как узнать, что установка прошла корректно? Вот совет: запустите встроенный набор тестов Python.
$ python -m test 0:00:00 load avg: 1.89 Run tests sequentially 0:00:00 load avg: 1.89 [ 1/423] test_grammar 0:00:00 load avg: 1.89 [ 2/423] test_opcodes 0:00:00 load avg: 1.89 [ 3/423] test_dict 0:00:00 load avg: 1.89 [ 4/423] test_builtin 0:00:00 load avg: 1.98 [ 5/423] test_exceptions 0:00:01 load avg: 1.98 [ 6/423] test_types 0:00:01 load avg: 1.98 [ 7/423] test_unittest 0:00:03 load avg: 1.98 [ 8/423] test_doctest ... Total duration: 18 min 9 sec Tests result: SUCCESS
В этой версии Python содержит 423 внутренних теста. Просто расслабьтесь и наслаждайтесь тем, как тесты проходят один за другим. 😎
Настройка виртуального окружения и установка пакетов
Для управления Python-окружениями и установки пакетов мы будем использовать самые базовые инструменты — venv и pip. Оба поставляются со стандартным Python, просты в использовании, и, скорее всего, большинство начинающих Python-разработчиков уже с ними знакомы.
# Создание виртуального окружения python -m venv .venv # Активация виртуального окружения source .venv/bin/activate # Обновление pip pip install --upgrade pip
# Создание проекта poetry poetry init --no-interaction # Добавление numpy как зависимости poetry add numpy # Пересоздание проекта на основе pyproject.toml poetry install # Получение пути к виртуальному окружению poetry (для PyCharm) poetry env info
Когда виртуальное окружение настроено и активировано, можно приступить к установке Python-пакетов. Чтобы продакшен-окружение было максимально лёгким, стоит разделить пакеты, необходимые для dev и prod:
dev: используются только для разработки (например, тестирование, линтинг и т. д.) и не нужны в продакшене. prod: необходимы в продакшене (например, обработка данных, машинное обучение и т. д.).
# Установка dev-пакетов, которые будут использоваться для тестирования, линтинга, проверки типов и т. д. pip install pytest pytest-cov pylint mypy codecov # Фиксация dev-зависимостей pip freeze > requirements.dev # Установка prod-пакетов pip install pandas # Фиксация prod-зависимостей pip freeze > requirements.prod
В этом простом проекте я вручную почистил файлы requirements (то есть удалил низкоуровневые зависимости, которые требуются установленным выше пакетам). В более крупных проектах с множеством пакетов и зависимостей лучше использовать инструмент управления зависимостями (обсуждается ниже). Вот как выглядят текущие файлы requirements:
$ cat requirements.dev codecov==2.1.7 mypy==0.780 pylint==2.5.3 pytest==5.4.3 pytest-cov==2.10.0 Sphinx==3.1.1 $ cat requirements.prod numpy==1.18.5 pandas==1.0.4
Когда у вас большой проект с множеством пакетов, обновление любого из них может сломать остальные. Эту проблему можно смягчить с помощью инструмента управления зависимостями. Такие инструменты также должны позволять легко разделять зависимости для dev и prod.
Я не включил это в основную часть статьи, так как единого мнения по ним пока нет, но вот несколько вариантов:
pipenv: выглядел многообещающе в 2018 году. Но я нашёл его глючным и медленным, что снижало скорость сборки. Тем не менее, с обновлениями 2020 года ситуация, похоже, улучшилась. pip-tools: работает со стандартным requirements.txt и позволяет просто обновлять зависимости через pip-compile --upgrade. Выглядит перспективно. poetry: в последнее время набирает популярность и позволяет напрямую собирать и публиковать пакеты в PyPI. Версия 1.0.0 была выпущена в декабре 2019 года. Когда-нибудь может стать стандартом де-факто.
Другие альтернативы: pyflow, dephell и flit. Мой друг Yu Xuan написал о средах Python и различных инструментах. Подробнее об этом здесь.
Альтернатива: Docker в качестве среды разработки
Альтернатива управлению версиями Python (через pyenv) и зависимостями (через venv) — использование Docker в качестве среды разработки. Таким образом команда может гарантировать единообразие среды ОС (а не только Python-окружения). Кроме того, некоторые ML-платформы, например SageMaker, запускают обучение и деплой через Docker-контейнеры — поэтому хорошей практикой является разработка в тех же контейнерах. Сейчас я рекомендую именно этот подход.
Начнём с создания простого Dockerfile, который базируется на Python3.8, копирует наши requirements и устанавливает зависимости.
ARG BASE_IMAGE=python:3.8 FROM ${BASE_IMAGE} as base LABEL maintainer='eugeneyan <[email protected]>' # Используем директорию opt как рабочую директорию для разработки WORKDIR /opt ENV PYTHONUNBUFFERED TRUE COPY requirements.dev . COPY requirements.prod . # Установка python-зависимостей RUN pip install --upgrade pip \ && pip install --no-cache-dir wheel \ && pip install --no-cache-dir -r requirements.dev \ && pip install --no-cache-dir -r requirements.prod \ && pip list
Чтобы собрать наше окружение, просто выполняем:
DOCKER_BUILDKIT=1 docker build -t dev -f Dockerfile .
Затем мы работаем через IDE на локальной машине как обычно. А когда нужно запустить тесты в Docker-контейнере, поднимаем его следующим образом. Эта команда монтирует текущую директорию в директорию /opt внутри Docker-контейнера и выполняет команду bash.
docker run --rm -it --name run-checks -v $(pwd):/opt -t dev bash
Создание единообразной структуры проекта
Тут немного что сказать — у нас будут стандартные директории src и tests. Директория tests должна повторять структуру директории src.
├── requirements.dev ├── requirements.prod ├── src │ ├── __init__.py │ └── data_prep └── tests ├── __init__.py └── data_prep
Добавление базовых методов
Прежде чем двигаться дальше, нам понадобится код для тестирования.
Для этой статьи я написал несколько базовых методов для обработки данных из набора данных Titanic. (Говорят, никогда не стоит писать о датасетах Titanic или MNIST, но вы же понимаете, что фокус этой статьи не на данных, верно?)
У нас есть модуль categorical для обработки имён и извлечения титулов (например, Mr, Mrs, Miss) и модуль continuous для заполнения пропущенных значений (в частности, возраста). Вот как выглядит метод extract_title. Остальной код src находится здесь.
def extract_title(df: pd.DataFrame, col: str, replace_dict: dict = None, title_col: str = 'title') -> pd.DataFrame: """Извлекает титулы в новый столбец title Args: df: DataFrame, из которого извлекаются титулы col: Столбец в DataFrame, из которого извлекаются титулы replace_dict (Optional): Необязательный словарь для сопоставления титулов title_col: Имя нового столбца с извлечёнными титулами Returns: DataFrame с дополнительным столбцом извлечённых титулов """ df[title_col] = df[col].str.extract(r' ([A-Za-z]+)\\.', expand=False) if replace_dict: df[title_col] = np.where(df[title_col].isin(replace_dict.keys()), df[title_col].map(replace_dict), df[title_col]) return df
Пишем юнит-тесты — наша страховочная сетка
Юнит-тесты проверяют работоспособность наших методов. Это важно для того, чтобы обновления ничего не ломали, особенно в data science, где ошибки в преобразовании данных и feature engineering обычно проходят незаметно. Поэтому мы включим тестовые данные (mock data) в наши тесты.
«Тесты — это истории, которые мы рассказываем следующему поколению программистов проекта.» — Roy Osherove
Стандарт де-факто для юнит-тестов — pytest. Также есть unittest, который поставляется с Python (хотя большинство просто используют pytest).
Чтобы протестировать наш метод extract_title, нужно создать тестовый DataFrame. Вот как это выглядит:
def test_extract_title(): string_col = ['futrelle, mme. jacques heath (lily may peel)', 'johnston, ms. catherine helen "carrie"', 'sloper, mr. william thompson', 'ostby, lady. engelhart cornelius', 'backstrom, major. karl alfred (maria mathilda gustafsson)'] df_dict = {'string': string_col} lowercased_df = pd.DataFrame(df_dict) result = extract_title(lowercased_df, col='string') assert result['title'].tolist() == ['mme', 'ms', 'mr', 'lady', 'major']
Запуск теста — это просто:
$ pytest ============================= test session starts ============================== platform darwin -- Python 3.8.2, pytest-5.4.3, py-1.8.2, pluggy-0.13.1 rootdir: /Users/eugene/projects/python-collab-template/tests/data_prep collected 1 item test_categorical.py::test_lowercase_column PASSED [100%] ============================== 1 passed in 0.24s ===============================
Вы заметите, что метод extract_title позволяет передать необязательный словарь для замены титулов. Например, мы можем захотеть заменить 'ms' на 'miss', а 'lady' и 'major' на 'rare'. Это тоже нужно протестировать.
Используйте фикстуры для повторного использования
Вместо копирования кода создания DataFrame можно оформить его как фикстуру для повторного использования. Это легко сделать с помощью декоратора pytest.fixture. Теперь мы можем использовать этот lowercase_df во всех тестах и соблюдать принцип DRY.
@pytest.fixture def lowercased_df(): string_col = ['futrelle, mme. jacques heath (lily may peel)', 'johnston, ms. catherine helen "carrie"', 'sloper, mr. william thompson', 'ostby, lady. engelhart cornelius', 'backstrom, major. karl alfred (maria mathilda gustafsson)'] df_dict = {'string': string_col} df = pd.DataFrame(df_dict) return df def test_extract_title(lowercased_df): result = extract_title(lowercased_df, col='string') assert result['title'].tolist() == ['mme', 'ms', 'mr', 'lady', 'major'] def test_extract_title_with_replacement(lowercased_df): title_replacement = {'mme': 'mrs', 'ms': 'miss', 'lady': 'rare', 'major': 'rare'} result = extract_title(lowercased_df, col='string', replace_dict=title_replacement) assert result['title'].tolist() == ['mrs', 'miss', 'mr', 'rare', 'rare']
Запустим тесты снова. Отлично. 👍
$ pytest ============================= test session starts ============================== platform darwin -- Python 3.8.2, pytest-5.4.3, py-1.8.2, pluggy-0.13.1 rootdir: /Users/eugene/projects/python-collab-template/tests/data_prep collected 2 items test_categorical.py::test_extract_title PASSED [ 50%] test_categorical.py::test_extract_title_with_replacement PASSED [100%] ============================== 2 passed in 0.30s ===============================
Тестируйте и исключения тоже
В методе fill_numeric пользователь может выбрать способ заполнения пропущенных значений, например mean, median и т. д. Если пользователь выберет некорректный способ, метод должен вернуть NotImplementedError. Это тоже можно протестировать в pytest.
def test_fill_numeric_not_implemented(dummy_df): with pytest.raises(NotImplementedError): fill_numeric(dummy_df, col='int', fill_type='random')
Чтобы лучше оценить ценность юнит-тестов, намеренно внесём баг: будем проверять, равен ли replace_dict значению True, вместо того чтобы проверять, был ли передан словарь.
def extract_title(df: pd.DataFrame, col: str, replace_dict: dict = None, title_col: str = 'title') -> pd.DataFrame: df[title_col] = df[col].str.extract(r' ([A-Za-z]+)\\.', expand=False) if replace_dict == True: # БАГ ВНЕСЁН ЗДЕСЬ df[title_col] = np.where(df[title_col].isin(replace_dict.keys()), df[title_col].map(replace_dict), df[title_col]) return df
Повторный запуск тестов немедленно обнаружит этот баг, выдаст ошибку и не позволит сборке продолжиться.
$ pytest --pyargs tests/data_prep/test_categorical.py ============================= test session starts ============================== platform darwin -- Python 3.8.2, pytest-5.4.3, py-1.8.2, pluggy-0.13.1 rootdir: /Users/eugene/projects/python-collab-template plugins: cov-2.10.0 collected 4 items tests/data_prep/test_categorical.py ...F [100%] =================================== FAILURES =================================== _____________________ test_extract_title_with_replacement ______________________ def test_extract_title_with_replacement(lowercased_df): title_replacement = {'mme': 'mrs', 'ms': 'miss', 'lady': 'rare', 'major': 'rare'} result = extract_title(lowercased_df, col='string', replace_dict=title_replacement) > assert result['title'].tolist() == ['mrs', 'miss', 'mr', 'rare', 'rare'] E AssertionError: assert ['mme', 'ms',...ady', 'major'] == ['mrs', 'miss...rare', 'rare'] E At index 0 diff: 'mme' != 'mrs' E Use -v to get the full diff tests/data_prep/test_categorical.py:50: AssertionError =========================== short test summary info ============================ FAILED tests/data_prep/test_categorical.py::test_extract_title_with_replacement
Таким образом мы узнаём, когда какие-либо изменения что-то ломают или вызывают регрессию.
Проверка покрытия: насколько полны наши тесты?
Теперь, когда у нас есть тесты, можно проверить, насколько они полны (то есть покрытие кода). Какие части кода были выполнены (через юнит-тесты), а какие пропущены? Для этого мы используем Coverage.py и устанавливаем его вместе с pytest-cov, который интегрирует Coverage.py с pytest.
Измерить покрытие так же просто, как добавить опцию --cov.
$ pytest --cov=src ============================= test session starts ============================== platform darwin -- Python 3.8.2, pytest-5.4.3, py-1.8.2, pluggy-0.13.1 rootdir: /Users/eugene/projects/python-collab-template plugins: cov-2.10.0 collected 9 items tests/data_prep/test_categorical.py .... [ 44%] tests/data_prep/test_continuous.py ..... [100%] ---------- coverage: platform darwin, python 3.8.2-final-0 ----------- Name Stmts Miss Cover -------------------------------------------------- src/__init__.py 0 0 100% src/data_prep/__init__.py 0 0 100% src/data_prep/categorical.py 12 0 100% src/data_prep/continuous.py 11 0 100% -------------------------------------------------- TOTAL 23 0 100% ============================== 9 passed in 0.49s ===============================
В примере выше покрытие кода составляет 100%. Что произойдёт, если оно не 100%? Для демонстрации удалим test_extract_title_with_replacement и снова запустим pytest. Строки кода без покрытия можно определить с помощью --cov-report=term-missing.
pytest --cov=src --cov-report=term-missing ============================= test session starts ============================== platform darwin -- Python 3.8.2, pytest-5.4.3, py-1.8.2, pluggy-0.13.1 rootdir: /Users/eugene/projects/python-collab-template plugins: cov-2.10.0 collected 8 items tests/data_prep/test_categorical.py ... [ 37%] tests/data_prep/test_continuous.py ..... [100%] ---------- coverage: platform darwin, python 3.8.2-final-0 ----------- Name Stmts Miss Cover Missing ------------------------------------------------------------ src/__init__.py 0 0 100% src/data_prep/__init__.py 0 0 100% src/data_prep/categorical.py 12 1 92% 50 src/data_prep/continuous.py 11 0 100% ------------------------------------------------------------ TOTAL 23 1 96% ============================== 8 passed in 0.44s ===============================
Здесь покрытие data_prep.categorical составляет 92%, причём строка 50 не выполняется нашими юнит-тестами. Восстановление test_extract_title_with_replacement исправляет это.
Линтинг для обеспечения единообразия (между проектами)
Обеспечить единообразие кода внутри одного проекта бывает непросто, не говоря уже о нескольких проектах. Небрежный, непоследовательный код затрудняет работу над разными проектами, а иногда приводит к багам. Здесь на помощь приходит линтер. Линтеры анализируют код, выявляя ошибки программирования, баги и отклонения от стандартов.
В этой статье мы используем pylint. Популярная альтернатива — flake8. В чём разница? Если коротко, pylint считается надмножеством flake8 и может выдавать ложные срабатывания.
Также обратите внимание на недавно выпущенный GitHub Super Linter.
Вот результат запуска pylint на нашем (изначально непоследовательном) коде. Он указывает, в каких строках есть проблемы, и даёт рекомендации по их исправлению. Мы также получаем сводку ошибок линтинга и оценку (4.17/10 😢).
$ pylint src.data_prep.categorical --reports=y ************* Module src.data_prep.categorical src/data_prep/categorical.py:20:0: C0330: Wrong continued indentation (add 9 spaces). df[title_col].map(replace_dict), ^ | (bad-continuation) src/data_prep/categorical.py:21:0: C0330: Wrong continued indentation (add 9 spaces). df[title_col]) ^ | (bad-continuation) src/data_prep/categorical.py:16:12: W1401: Anomalous backslash in string: '\.'. String constant might be missing an r prefix. (anomalous-backslash-in-string) src/data_prep/categorical.py:1:0: C0114: Missing module docstring (missing-module-docstring) src/data_prep/categorical.py:5:0: C0116: Missing function or method docstring (missing-function-docstring) src/data_prep/categorical.py:9:0: C0116: Missing function or method docstring (missing-function-docstring) src/data_prep/categorical.py:14:0: C0116: Missing function or method docstring (missing-function-docstring) Report ====== 12 statements analysed. ... Messages -------- +------------------------------+------------+ |message id |occurrences | +==============================+============+ |missing-function-docstring |3 | +------------------------------+------------+ |bad-continuation |2 | +------------------------------+------------+ |missing-module-docstring |1 | +------------------------------+------------+ |anomalous-backslash-in-string |1 | +------------------------------+------------+ ----------------------------------- Your code has been rated at 4.17/10
Приведём код в порядок и запустим pylint снова.
pylint src.data_prep.categorical --reports=y Report ====== ... ------------------------------------------------------------------- Your code has been rated at 10.00/10 (previous run: 4.17/10, +5.83)
Выглядит гораздо лучше (10/10).
Проверка типов для предотвращения ошибок
Кто здесь любит аннотации типов? 🙋🏻♂️
С тех пор как я начал работать со Scala (и Spark), я полюбил указывать типы. Я был в восторге, когда модуль typing появился в Python 3.5. Указание типов даёт несколько преимуществ:
Информативность: пользователи знают, что передавать на вход и что ожидать на выходе Обработка ошибок: если передан неправильный тип, код выдаст предупреждение Простота поддержки и отладки
Среда выполнения Python не проверяет аннотации типов — язык динамически типизирован и проверяет типы (во время выполнения) только через утиную типизацию. Тем не менее, мы можем использовать аннотации типов и статический анализатор для проверки корректности типов в нашем коде. Для этого мы будем использовать mypy.
$ mypy src Success: no issues found in 4 source files
Примечание: некоторые компании из FAANG также выпустили собственные проверщики типов. Стоит обратить внимание на pytype (Google), pyre (Facebook) и pyright (Microsoft).
Что если типы указаны неправильно? Для демонстрации обновим fill_numeric, чтобы он заполнял пропущенные значения строкой '-1' (тип str) вместо числа -1 (тип int). mypy немедленно укажет на несовместимые типы.
$ mypy src src/data_prep/continuous.py:23: error: Incompatible types in assignment (expression has type "str", variable has type "float") Found 1 error in 1 file (checked 4 source files)
Обёртка для удобства разработчика
Теперь у нас есть основные элементы: юнит-тесты, отчёты о покрытии, линтинг, проверка типов. По мере роста проекта рабочий процесс будет включать всё больше проверок, и набирать все эти команды вручную станет утомительно. Чем сложнее использование, тем меньше людей будут этим пользоваться.
Мы воспользуемся make и обернём эти команды в Makefile. Makefile определяет набор задач и команд, необходимых для их выполнения. Цель — предоставить простой интерфейс и стимулировать использование наших проверок. Вот как выглядит задача test в Makefile — она запускает команду pytest с параметрами.
test: . .venv/bin/activate && py.test tests --cov=src --cov-report=term-missing --cov-fail-under 95
И как это запустить.
$ make test . .venv/bin/activate && py.test tests --cov=src --cov-report=term-missing --cov-fail-under 95 ============================= test session starts ============================== platform darwin -- Python 3.8.2, pytest-5.4.3, py-1.8.2, pluggy-0.13.1 rootdir: /Users/eugene/projects/python-collab-template plugins: cov-2.10.0 collected 8 items tests/data_prep/test_categorical.py ... [ 37%] tests/data_prep/test_continuous.py ..... [100%] ---------- coverage: platform darwin, python 3.8.2-final-0 ----------- Name Stmts Miss Cover Missing ------------------------------------------------------------ src/__init__.py 0 0 100% src/data_prep/__init__.py 0 0 100% src/data_prep/categorical.py 12 0 100% src/data_prep/continuous.py 11 0 100% ------------------------------------------------------------ TOTAL 23 0 100% Required test coverage of 95% reached. Total coverage: 100.00% ============================== 8 passed in 1.05s ===============================
Таким образом, вместо активации окружения, запоминания параметров и ввода длинной команды — или копирования — можно просто набрать make test.
Перед запуском тестов также полезно очистить существующие файлы pyc и отчёт о покрытии. Их тоже можно добавить в Makefile и связать с задачей test следующим образом.
clean-pyc: find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + clean-test: rm -f .coverage rm -f .coverage.* clean: clean-pyc clean-test test: clean . .venv/bin/activate && py.test tests --cov=src --cov-report=term-missing --cov-fail-under 95
Затем можно создать задачу make check для запуска полного набора проверок. Вот как это выглядит в Makefile. (Полный Makefile смотрите в репозитории.)
checks: test lint mypy
Автоматизация проверок при каждом git push (и pull request)
Код-ревью занимает много времени. Мы можем снизить нагрузку на ревьюеров, убедившись, что код работает — запуская проверки перед каждым pull request. Ревью ошибочного pull request — пустая трата времени; такие ошибки можно и нужно минимизировать с помощью автоматических проверок.
Кроме того, хотя Makefile упрощает запуск проверок локально, иногда что-то проскальзывает. Мы можем торопиться исправить критический баг и забыть запустить проверки перед созданием и мержем хотфикса, тем самым создав ещё больше багов.
«Чем раньше вы обнаруживаете дефекты, тем дешевле их исправлять.» — David Farley
Поэтому мы будем автоматически запускать проверки перед каждым pull request (при каждом событии push). Это можно сделать с помощью GitHub Actions, добавив файл tests.yml в директорию .github/workflows.
# .github/workflows/tests.yml name: Tests on: push jobs: tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 with: python-version: 3.8 architecture: x64 - run: make setup-venv - run: make checks
При каждом push рабочий процесс tests будет запускаться на последнем образе ubuntu с Python версии 3.8. Сначала он настраивает окружение (make setup), устанавливая необходимые пакеты, а затем запускает проверки (make check).
Вот что происходит, когда pull request вносит баг. Рабочий процесс запускается и сообщает, что одна из проверок не пройдена. Это должно побудить нас исправить проблемы и избавляет от ненужных код-ревью.
Мы экономим время, не проводя ревью ошибочных pull request.
После обновления pull request с исправлением бага GitHub сообщает, что все проверки пройдены — теперь можно проводить ревью. По грубым оценкам, это сэкономило моим предыдущим командам около 50–60% усилий на код-ревью. Да, мы вносили (и, к счастью, предотвращали) ошибки с каждым PR.
Теперь можно приступить к код-ревью.
Этот pull request можно посмотреть здесь.
Поддержание ветки master без ошибок даёт нам этот блестящий бейдж.
Мы можем подключить сервис отчётов о покрытии, чтобы покрытие кода было более наглядным. Для этого используем codecov.
Зарегистрируйте аккаунт на Codecov и добавьте туда свой репозиторий. Затем следуйте инструкциям и загрузите CODECOV_TOKEN как секрет в ваш репозиторий. Также добавим последнюю строку в наш файл tests.yml, чтобы он выглядел так:
# .github/workflows/tests.yml name: Tests on: push jobs: tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 with: python-version: 3.8 architecture: x64 - run: make setup - run: make check - run: bash <(curl -s https://codecov.io/bash)
Это позволяет добавить бейдж codecov.
Применяйте эти практики и извлекайте выгоду
Спасибо, что дочитали до этого места. Мы рассмотрели основы настройки Python и управления пакетами, юнит-тестирование, покрытие кода, линтинг, проверку типов, Makefile и автоматический запуск проверок при каждом push (и pull request).
Репозиторий с кодом к этой статье доступен здесь 🌟.
Эти практики обеспечивают качество кода, уменьшают количество багов и экономят усилия. Начните с форка репозитория, обновите код (намеренно с багами) и создайте pull request. Или клонируйте репозиторий и попробуйте что-нибудь сломать. Расскажите, как всё прошло, в комментариях ниже.
P.S. Была ли эта статья полезной для вас? Если да, пожалуйста, поделитесь этим твитом. 👇
Дополнительное чтение
Управление несколькими версиями Python с помощью pyenv Как использовать статическую проверку типов в Python 3.6 Hypermodern Python
Благодарности Yang Xinyi, Stew Fortier и Joel Christiansen за чтение черновиков этой статьи.
P.S. Многому из этого я научился у Zhao Chuxin. Всё хорошее — его заслуга; ошибки — только мои.
Если вы нашли это полезным, пожалуйста, цитируйте эту статью следующим образом:
Yan, Ziyou. (Jun 2020). How to Set Up a Python Project For Automation and Collaboration. eugeneyan.com. https://eugeneyan.com/writing/setting-up-python-project-for-automation-and-collaboration/.
или
@article{yan2020python, title = {How to Set Up a Python Project For Automation and Collaboration}, author = {Yan, Ziyou}, journal = {eugeneyan.com}, year = {2020}, month = {Jun}, url = {https://eugeneyan.com/writing/setting-up-python-project-for-automation-and-collaboration/} }
Присоединяйтесь к 11 800+ читателям, получающим обновления о машинном обучении, RecSys, LLM и инженерии.