Перевод был сделан для платформы курсов по программированию Хекслет

Эта вторая статья из цикла, в котором рассказывается о лучших практиках современного Python. В этом цикле статей все примеры основаны на реализации простого проекта, который представляет собой функцию Python, которая суммирует данные, присутствующие в pandas DataFrame. Функция выводит количество строк и столбцов и частоту каждого типа данных, присутствующих в pandas DataFrame.

Хорошая практика разработки программного обеспечения всегда приносит много долгосрочных выгод. Например, написание модульных тестов позволяет поддерживать большие кодовые базы и гарантирует, что определенный фрагмент вашего кода ведет себя так, как ожидается. Написание последовательных коммитов в Git также улучшает сотрудничество между участниками проекта. Хорошо составленные сообщения коммитов Git открывают путь к автоматическому версионированию и автоматически генерируемым файлам журнала изменений. Поэтому сейчас в сообществе разработчиков предпринимается множество попыток нормализовать сообщения, написанные в наших Git-коммитах.

В первой части этой серии мы настраивали наш проект, устанавливая различные версии Python с помощью pyenv, устанавливая локальную версию Python с помощью pyenv, инкапсулируя его в виртуальную среду с помощью poetry. Здесь мы покажем более подробно, как проводить модульное тестирование вашего Python-приложения и как обеспечить и проверить сообщения о коммитах в Git. Исходный код, связанный с этой статьей, опубликован на GitHub.

Тестирование кода

Перейдите в корневой каталог вашего проекта и активируйте виртуальное окружение:

cd summarize_dataframe/
poetry shell

Примечание переводчика: Поскольку со времени написания оригинальной статьи требования numpy к версии Python изменились, перед добавлением зависимостей необходимо внести изменения в файл pyproject.toml:

[tool.poetry.dependencies]
-python = "^3.8"
+python = ">=3.8,<3.11"

Добавим несколько зависимостей с помощью poetry:

poetry add -D pynvim numpy pandas

Флаг -D указывает, что зависимость применяется только к среде разработки.

Исходя из ожидаемого результата работы проекта, наша программа состоит из трех шагов:

  1. Получение формы pandas DataFrame

  2. Получение частоты типов pandas dtypes.

  3. Объединение этих двух результатов в единый DataFrame, который будет использован для вывода окончательного результата.

После получения окончательного DataFrame выводится результат, как показано выше. В связи с этим наш код может выглядеть следующим образом:

import pandas as pd


def data_summary(df: pd.DataFrame) -> None:
    """
    Function defined to return a DataFrame containing details
    about the number of rows and columns and the column dtype
    frequency of the passed pandas DataFrame
    """
    
    def _shape(df: pd.DataFrame) -> None:
        """
        Function defined to return a dataframe with details about
        the number of row and columns
        """
        return None
     
    def _dtypes_freq(df: pd.DataFrame) -> None:
      	"""
        Function defined to return a dataframe with details about
        the pandas dtypes frequency
        """
        return None
    
    return None

def display_summary(df: pd.DataFrame) -> None:
    """
    Function define to print out the result of the data summary
    """
    result_df = True
    message = '---- Data summary ----'
    print(message, result_df, sep='\n')

Теперь начнем писать модульные тесты. Мы будем использовать инструмент unittest, доступный в стандартной библиотеке Python. Возможно вы помните, что в предыдущей статье pytest был определен как зависимость для тестирования. Это не проблема, потому что pytest нативно запускает тесты, написанные с помощью библиотеки unittest.

Юнит-тесты — это методы, которые, как ожидает unittest, будут описаны внутри классов Python. Выберите для своих тестовых классов и методов описывающее имя — оно должно начинаться с test_. Дополнительно unittest использует ряд специальных тестовых методов, унаследованных от класса unittest.TestCase.

На практике тест должен:

  • Охватывать одну функцию

  • Быть автономным

  • Не требовать внешних инструкций

  • Воссоздавать условия достижения результата.

Чтобы воссоздать необходимую рабочую среду, необходимо написать код настройки. Если этот код окажется избыточным, реализуйте метод setUp(), который будет выполняться перед каждым тестом. Это очень удобно для повторного использования и реорганизации кода. В зависимости от сценария использования, возможно придется выполнять регулярные операции после выполнения тестов. Для этого вы можете использовать метод tearDown().

Сначала вы можете посмотреть unit-тест, который мы реализовали для функции data_summary():

import unittest
import pandas as pd
from summarize_dataframe.summarize_df import data_summary


class TestDataSummary(unittest.TestCase):

  	def setUp(self):
      
				# initialize dataframe to test
        df_data = [[1, 'a'], [2, 'b'], [3, 'c']]
				df_cols = ['numbers', 'letters']
				self.df = pd.DataFrame(data=df_data, columns=df_cols)
        
				# initialize expected dataframe
        exp_col = ['Values']
				exp_idx = ['Number of rows', 'Number of columns', 'int64', 'object']
				exp_data = [[3], [2], [1], [1]]
				self.exp_df = pd.DataFrame(data=exp_data, columns=exp_col, index=exp_idx)
    
    def test_data_summary(self):
        expected_df = self.exp_df
        result_df = data_summary(self.df)
        self.assertTrue(expected_df.equals(result_df))        

if __name__ == '__main__':
		unittest.main()

Метод setUp() инициализирует два разных pandas DataFrame. self.exp_df — это результирующий DataFrame, который мы ожидаем получить после вызова функции data_summary(), а self.df — это DataFrame, который используется для тестирования наших функций. Сейчас ожидается, что тесты окажутся неудачными, потому что логика не была реализована. Для тестирования с помощью poetry используйте команду:

poetry run pytest -v

==================================================== test session starts ====================================================
platform darwin -- Python 3.8.7, pytest-5.4.3, py-1.11.0, pluggy-0.13.1 -- /Users/aabur/Documents/GitHub/AABur/modern_python/summarize_dataframe/.venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/aabur/Documents/GitHub/AABur/modern_python/summarize_dataframe
collected 1 item
tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary FAILED                                          [100%]
========================================================= FAILURES ==========================================================
_____________________________________________ TestDataSummary.test_data_summary _____________________________________________
self = 

    def test_data_summary(self):
        expected_df = self.exp_df
        result_df = data_summary(self.df)
>       self.assertTrue(expected_df.equals(result_df))
E       AssertionError: False is not true

tests/test_summarize_dataframe.py:21: AssertionError
================================================== short test summary info ==================================================
FAILED tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary - AssertionError: False is not true
===================================================== 1 failed in 0.39s =====================================================

Использование флага -v позволяет получить более подробный вывод результатов тестирования. Вы можете видеть, что тесты помечены в соответствии с именами классов и функций, которые вы задали. В нашем случае это ::TestDataSummary::test_data_summary.

Поменяем код для соответствия unit-тестам:

import pandas as pd


def data_summary(df: pd.DataFrame) -> pd.DataFrame:
    """
    Function defined to output details about the number
    of rows and columns and the column dtype frequency of
    the passed pandas DataFrame
    """
    
    def _shape(df: pd.DataFrame) -> pd.DataFrame:
        """
        Function defined to return a dataframe with details about
        the number of row and columns
        """
        row, col = df.shape
        return pd.DataFrame(data=[[row], [col]], columns=['Values'], index=['Number of rows', 'Number of columns'])

    def _dtypes_freq(df: pd.DataFrame) -> pd.DataFrame:
        """
        Function defined to return a dataframe with details about
        the pandas dtypes frequency
        """
        counter, types = {}, df.dtypes
        for dtype in types:
            tmp = str(dtype)
            if tmp in counter.keys():
                counter[tmp] += 1
            else:
                counter[tmp] = 1
        values = [[value] for value in counter.values()]
        return pd.DataFrame(
          	data=values,
          	columns=['Values'],
          	index=list(counter.keys())
        )
 
    result_df = pd.concat([_shape(df), _dtypes_freq(df)])
    return result_df


def display_summary(df: pd.DataFrame) -> None:
    """
    Function define to print out the result of the data summary
    """
    result_df = True
    message = '---- Data summary ----'
    print(message, result_df, sep='\n')

Снова запустим тесты:

poetry run pytest -v

==================================================== test session starts ====================================================
platform darwin -- Python 3.8.7, pytest-5.4.3, py-1.11.0, pluggy-0.13.1 -- /Users/aabur/Documents/GitHub/AABur/modern_python/summarize_dataframe/.venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/aabur/Documents/GitHub/AABur/modern_python/summarize_dataframe
collected 1 item
tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary PASSED                                          [100%]
===================================================== 1 passed in 0.35s =====================================================

И последнее. В наших тестах мы не проверяли фактический вывод. Наш модуль предназначен для вывода строкового представления сводки DataFrame. Существуют решения для достижения этой цели с помощью unittest, но мы будем использовать pytest для этого теста. Удивительно, не правда ли? Как уже говорилось, pytest очень хорошо работает с unittest, и сейчас мы это проиллюстрируем. Вот код для этого теста:

import unittest
import pytest
import pandas as pd
from summarize_dataframe.summarize_df import data_summary, display_summary


class TestDataSummary(unittest.TestCase):
  
    def setUp(self):
      
        # initialize dataframe to test
        df_data = [[1, 'a'], [2, 'b'], [3, 'c']]
        df_cols = ['numbers', 'letters']
        self.df = pd.DataFrame(data=df_data, columns=df_cols)
        
        # initialize expected dataframe
        exp_col = ['Values']
        exp_idx = ['Number of rows', 'Number of columns', 'int64', 'object']
        exp_data = [[3], [2], [1], [1]]
        self.exp_df = pd.DataFrame(
            data=exp_data, columns=exp_col, index=exp_idx)

    @pytest.fixture(autouse=True)
    def _pass_fixture(self, capsys):
        self.capsys = capsys

    def test_data_summary(self):
        expected_df = self.exp_df
        result_df = data_summary(self.df)
        self.assertTrue(expected_df.equals(result_df))

    def test_display(self):
        print('---- Data summary ----', self.exp_df, sep='\n')
        expected_stdout = self.capsys.readouterr()
        display_summary(self.df)
        result_stdout = self.capsys.readouterr()
        self.assertEqual(expected_stdout, result_stdout)

if __name__ == '__main__':
    unittest.main()

Обратите внимание на декоратор @pytest.fixture(autouse=True) и функцию, которую он оборачивает (_pass_fixture).  В терминологии модульного тестирования этот метод называется фикстурой (fixture). Фикстуры - это функции (или методы, если Вы используете подход ООП), которые будут выполняться перед каждым тестом, к которому они применяются. Фикстуры используются для передачи данных в тесты. Они выполняют ту же задачу, что и метод setUp(), который мы использовали ранее. Здесь мы используем заранее определенное фикстуру под названием capsys для захвата стандартного вывода (stdout) и повторного использования его в нашем тесте. Теперь изменим соответствующим образом функцию display_summary():

import pandas as pd


def data_summary(df: pd.DataFrame) -> pd.DataFrame:
    """
    Function defined to output details about the number
    of rows and columns and the column dtype frequency of
    the passed pandas DataFrame
    """
    
    def _shape(df: pd.DataFrame) -> pd.DataFrame:
        """
        Function defined to return a dataframe with details about
        the number of row and columns
        """
        row, col = df.shape
        return pd.DataFrame(data=[[row], [col]], columns=['Values'], index=['Number of rows', 'Number of columns'])

    def _dtypes_freq(df: pd.DataFrame) -> pd.DataFrame:
        """
        Function defined to return a dataframe with details about
        the pandas dtypes frequency
        """
        counter, types = {}, df.dtypes
        for dtype in types:
            tmp = str(dtype)
            if tmp in counter.keys():
                counter[tmp] += 1
            else:
                counter[tmp] = 1
        values = [[value] for value in counter.values()]
        return pd.DataFrame(
          	data=values,
          	columns=['Values'],
          	index=list(counter.keys())
        )
    
    result_df = pd.concat([_shape(df), _dtypes_freq(df)])
    return result_df

def display_summary(df: pd.DataFrame) -> None:
    """
    Function define to print out the result of the data summary
    """
    result_df = data_summary(df)
    message = '---- Data summary ----'
    print(message, result_df, sep='\n')

Ещё раз запустим тесты:

poetry run pytest -v

==================================================== test session starts ====================================================
platform darwin -- Python 3.8.7, pytest-5.4.3, py-1.11.0, pluggy-0.13.1 -- /Users/aabur/Documents/GitHub/AABur/modern_python/summarize_dataframe/.venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/aabur/Documents/GitHub/AABur/modern_python/summarize_dataframe
collected 2 items
tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary PASSED                                          [ 50%]
tests/test_summarize_dataframe.py::TestDataSummary::test_display PASSED                                               [100%]
===================================================== 2 passed in 0.31s =====================================================

Тесты прошли успешно. Пришло время зафиксировать нашу работу и поделиться ею, например, опубликовав на GitHub. Перед этим давайте подробно рассмотрим, как правильно сообщать о нашей работе с помощью сообщений о коммитах Git, соблюдая и поддерживая единый стандарт.

Применение правил по созданию сообщений Git-коммитов в проекте Python

Написание оптимальных Git-коммит сообщений - непростая задача. Сообщения должны быть четкими, читаемыми и понятными в долгосрочной перспективе. Спецификация Conventional Commits предлагает набор правил для создания однозначной истории коммитов. На Хекслете есть большая статья, посвященная правильному именованию коммитов.

Использование commitizen

Мы будем использовать пакет commitizen для интеграции Conventional Commits в наш проект на Python. Добавим этот пакет в зависимости разработчика:

poetry add -D commitizen

Чтобы настроить commitizen для своего проекта, выполните команду cz init. Она предложит нам ответить на ряд вопросов:

cz init

? Please choose a supported config file: (default: pyproject.toml) (Use arrow keys)
» pyproject.toml
  .cz.toml
  .cz.json
  cz.json
  .cz.yaml
  cz.yaml
  
? Please choose a cz (commit rule): (default: cz_conventional_commits) (Use arrow keys)
» cz_conventional_commits
  cz_jira
  cz_customize
  
? Please enter the correct version format: (default: "$version")

? Do you want to install pre-commit hook? (Y/n)

Выберем здесь все варианты по умолчанию, так как они полностью соответствуют нашей реальной ситуации. Последний вопрос спрашивает нас, нужно ли использовать хук pre-commit. Мы собираемся вернуться к этому позже. Поэтому пока просто ответим «нет»(n). Если мы посмотрим на файл pyproject.toml, то увидим, что была добавлена новая запись под названием [tool.commitizen]:

[tool.commitizen]
name = "cz_conventional_commits" # правило формирования коммит-сообщений
version = "0.0.1"
tag_format = "$version"

Проверить коммит-сообщение, можно при помощи следующей команды:

cz check -m "all summarize_data tests now succeed"

commit validation: failed!
please enter a commit message in the commitizen format.
commit "": "all summarize_data tests now succeed"
pattern: (build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)!?((\S+))?:(\s.*)

Наше сообщение отклонено, потому что оно не соответствует выбранным правилам для коммит-сообщений. Последняя строка предлагает некоторые шаблоны для использования. Уделите немного времени чтению документации о соглашении о коммитах и выполните команду cz info, чтобы распечатать краткую документацию:

cz info

The commit contains the following structural elements, to communicate
intent to the consumers of your library:

fix: a commit of the type fix patches a bug in your codebase
(this correlates with PATCH in semantic versioning).

feat: a commit of the type feat introduces a new feature to the codebase
(this correlates with MINOR in semantic versioning).

BREAKING CHANGE: a commit that has the text BREAKING CHANGE: at the beginning of
its optional body or footer section introduces a breaking API change
(correlating with MAJOR in semantic versioning).
A BREAKING CHANGE can be part of commits of any type.

Others: commit types other than fix: and feat: are allowed,
like chore:, docs:, style:, refactor:, perf:, test:, and others.
[...]

Эта команда подскажет вам, как написать сообщение о коммите. Здесь формат должен быть таким: «[тип]: [СООБЩЕНИЕ]». Для нас это выглядит так:

cz check -m "test: all summarize_data tests now succeed"

Commit validation: successful!

Очень хорошо, наше коммит-сообщение считается корректным. Но подождите. Проверять коммит-сообщения каждый раз с помощью commitizen может быть утомительно, и это не даёт гарантии, что коммит будет принят. Было бы лучше автоматически проверять сообщение каждый раз, когда мы используем команду git commit. Именно в этом случае в действие вступает pre-commit хук.

Автоматическое соблюдение соглашений о Git-коммитах при помощи pre-commit

Хуки Git полезны для автоматизации и выполнения некоторых действий на разных этапах жизненного цикла Git. Хук pre-commit позволяет запускать скрипты до того, как будет выполнен Git-коммит. Мы можем использовать хук для проверки сообщений о коммитах и предотвращения использования Git сообщения, которое не соответствует нашим ожиданиям. Хук активен как из командной строки, так и из любых инструментов, взаимодействующих с репозиторием Git, в котором зарегистрирован хук, включая вашу любимую IDE.

pre-commit — это фреймворк для управления и поддержки многоязычных хуков pre-commit. Если вы хотите узнать больше о внутреннем устройстве и спектре возможностей pre-commit, то можете прочитать документацию по его использованию.

Чтобы установить pre-commit, просто выполните команду:

poetry add -D pre-commit

Для автоматизации проверки коммита Git нам сначала нужно создать конфигурационный файл .pre-commit-config.yaml:

---
repos:
  - repo: https://github.com/commitizen-tools/commitizen
    rev: master
    hooks:
      - id: commitizen
        stages: [commit-msg]

Далее мы можем установить хук с источником, определенным в параметре repo:

pre-commit install --hook-type commit-msg
pre-commit installed at .git/hooks/commit-msg

Теперь, когда все готово, мы можем использовать наш Git-хук:

git add tests/test_summarize_dataframe.py

git commit -m "test: all summarize_data tests now succeed"

[WARNING] Unstaged files detected.
[INFO] Stashing unstaged files to /Users/aabur/.cache/pre-commit/patch1637958717.
commitizen check.........................................................Passed
[INFO] Initializing environment for https://github.com/commitizen-tools/commitizen.
[INFO] Installing environment for https://github.com/commitizen-tools/commitizen.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
commitizen check.........................................................Passed
[INFO] Restored changes from /Users/aabur/.cache/pre-commit/patch1637958717.
[main 6ef0700] test: all summarize_data tests now succeed
1 file changed, 38 insertions(+), 5 deletions(-)
rewrite tests/test_summarize_dataframe.py (98%)

pre-commit устанавливает среду для выполнения своих проверок. Как вы можете видеть здесь, сообщение о коммите прошло проверку. В завершение мы можем закоммитить изменения, внесенные в файлы сборки (poetry.lockpyproject.toml) и наш модуль:

git add poetry.lock pyproject.toml

git commit -m "build: add developer dependencies"

commitizen check.........................................................Passed
[main 8e616bc] build: add developer dependencies
2 files changed, 664 insertions(+), 3 deletions(-)

git add .pre-commit-config.yaml

git commit -m "build: add pre-commit hook"

commitizen check.........................................................Passed
[main 60880cb] build: add pre-commit hook
1 file changed, 7 insertions(+)

git add summarize_dataframe/summarize_df.py

git commit -m "feat: implementation of the summary function to summarize dataframe"

commitizen check.........................................................Passed
[main 53a82a0] feat: implementation of the summary function to summarize dataframe
1 file changed, 42 insertions(+)

Теперь мы можем отправить все в наш репозиторий GitHub:

git push origin main

Enumerating objects: 18, done.
Counting objects: 100% (18/18), done.
Delta compression using up to 12 threads
Compressing objects: 100% (12/12), done.
Writing objects: 100% (12/12), 19.60 KiB | 6.53 MiB/s, done.
Total 12 (delta 3), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (3/3), completed with 2 local objects.
To github.com:AABur/summarize_dataframe.git
af38079..53a82a0  main -&gt; main

Заключение

Мы рассмотрели несколько тем:

  • На первом этапе мы узнали, как писать модульные тесты для вашего кода. Писать тесты до кода поможет вам уточнить API и ожидаемый результат до реализации в коде. Мы использовали unittest, который уже доступен в стандартной библиотеке Python. Также было продемонстрировано использование библиотеки pytest для написания тестов. Очень удобно то, что pytest с самого начала поддерживает класс unittest.TestCase. Вы можете писать свои тесты с помощью любой из двух библиотек или даже смешивать их в зависимости от ваших потребностей и иметь одну общую команду для запуска всех тестов.

  • Мы рассмотрели, как обеспечить соблюдение хороших практик при написании сообщений о коммитах в Git. Предлагаемое нами решение основано на использовании двух пакетов Python: commitizen и pre-commit. Первый предоставляет инструменты для проверки соответствия сообщения выбранным вами соглашениям. Второй автоматизирует процесс с помощью Git-хука.

Краткая памятка

poetry

  • Добавьте зависимости проекта

poetry add [package_name]

  • Установить глобальную версию Python

pyenv global

  • Установить локальную версию Python

pyenv local

poetry

  • Добавьте зависимости проекта

    poetry add [package_name]

  • Добавьте зависимости для разработки

    poetry add -D [package_name]

  • Запуск тестов

    poetry run pytest

commitizen

  • Инициализация commitizen

    cz init

  • Проверка коммита

    cz check -m "YOUR MESSAGE"

pre-commit

  • Создание файла конфигурации по умолчанию

    pre-commit sample-config

  • Установить git-хук

    pre-commit install --hook-type [hook_name]

Комментарии (6)


  1. Andy_U
    14.01.2022 22:45
    +1

    Вы бы проверили ваш код на работоспособность? А то и отступы испортились, и какой то html/css виден?


    1. AABur Автор
      14.01.2022 22:55

      Это проблема хабровского редактора - сейчас поправлю. Код тут https://github.com/AABur/summarize_dataframe


  1. QuAzI
    15.01.2022 08:03

    А только мне кажется, что Conventional Commits делались вообще не для людей? Поэтому IRL их особо не видать. Статья от хекслета отличная


    1. prefrontalCortex
      15.01.2022 08:35

      В каком-то опенсорсном проекте краем глаза видел подобные сообщения (точно не вспомню).


      1. QuAzI
        15.01.2022 10:03
        +3

        Ёжики кололись, но продолжали есть кактус, потому что понятнее варианта не нашли. Когда людей в репо (да и форков) становится более одного и начинают лететь непричёсанные мержи с кучей коммитов (историю же мы не теряем), пуллреквесты и прочее, версии зафиксированные где-то кем-то у себя на коленке в файлике превращаются в тыкву. Тем более что это ещё нужно вынудить массу людей строго ставить все эти не несущие полезной нагрузки тысячи "feat:" съедающих и без того короткий заголовок. Если пробежаться вскользь по упомянутым тем же хекслетом linux, git или в какой-нибудь KeePassXC, ShareX заглянуть, то там совсем другие выстраданные годами бестпрактисы и вообще основная разработка ведётся в бранчах (которые в свою очередь именуются как feature/***, hotfix/***, если немного про git flow), а затем мержится. Как будет в этом зоопарке вести себя cz - мне вообще не понятно. А вот номер таски в конце заголовка коммита видеть хочется, его потом можно удобно глянуть через git log --oneline, git shortlog без лишних телодвижений.
        Там же тянется семантическое версионирование, которое тоже заслуживает отдельных разборок. Потому что, например, version.rc для форточек требует 4 цифры, а валидаторы предложенные на semver.org радостно фейлят такое. Ещё интереснее становится когда хочешь чтобы CI на гитхабе сам собирал тебе релиз и собирал его через PyInstaller в готовую сборку, в которой идентична версия внутри приложения, в version.rc, в теге, в описании к релизу, без лишних инструментов и телодвижений.

        Плюс в теге хочется видеть не только искуственно выстраданный номер (который соответствует которому форку репозитория?), но и настоящий хэш коммита.


  1. AABur Автор
    15.01.2022 18:46

    "забавно" - единственная оценка -1 - "за личную неприязнь к автору или компании"

    У кого-то ко мне личная неприязнь?! Считаю это уже популярностью )))