О чем говорит версия проекта?

от автора

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

Какое-то время подходы к версионированию варьировались от компании к компании, до тех пор, пока соучредитель GitHub Том Престон-Вернер не предложил единый подход к выпуску и нумерации версий проектов. Этот подход называется Семантическим Версионированием и подробно описан в этом документе. В этом тексте я бы хотел проиллюстрировать основные положения документа и разобраться, что нам может рассказать версия проекта.

API

В своем документе Том Престон-Вернер предлагает использовать версии в формате X.Y.Z, где X, Y и Z — это целые неотрицательные числа. Думаю, каждый из нас хотя бы раз в жизни видел версии библиотек, записанные в таком формате. Например, актуальная версия библиотеки NumPy на момент публикации этого текста выглядит так 2.2.1. Что значат эти числа и почему их три? Об этом мы поговорим ниже. Прежде чем переходить к этому разговору, нужно разобраться, а что вообще определяет версия?

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

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

import logging  from typing import (     Any,     Callable,     Hashable,     TypeVar, )   T = TypeVar("T")   def register(     registry: dict[Hashable, Any],     key: Hashable, ) -> Callable[[T], T]:     def wrapper(obj: T) -> T:         registry[key] = obj         logging.debug(f"save <{key}: {obj}> into registry")         return obj      return wrapper

В данном коде мы определили параметризованный декоратор (насколько я знаю, подобные конструкции в Python называются именно так, но если я не прав, буду признателен комментариям с исправлениями). В качестве параметров он принимает словарь registry, в который и будет сохранен объект в момент своего определения, а также любой хешируемый объект key, который будет использован в качестве ключа для хранения объекта в словаре. В теле register определяется декоратор, который не делает ничего интеллектуального. Он просто сохраняет декорируемый объект в словарь registry по ключу key, с помощью модуля logging выводит отладочное сообщение об успешном сохранении пары ключ-значение в словарь, и возвращает декорируемый объект без изменений.

Именно этот функционал составляет публичный API нашей библиотеки. Теперь мы можем сказать, что наша библиотека, назовем ее Registrant, обладает публичным API в виде параметризованного декоратора register. Аргументы параметризованного декоратора мы описали в предыдущем абзаце. Результат выполнения параметризованного декоратора — декоратор wrapper, который принимает на вход произвольный объект, сохраняет его в словарь register по ключу key и возвращает его без изменений. Если registry — не словарь, декоратор wrapper возбудит TypeError, это тоже указываем в описании API.

Проиллюстрируем использование нашего декоратора юзкейсом:

registry = {}  @register(registry, "hello-printer") def print_hello() -> None:     print("Hello!")  print_hello() # Hello! registry["hello-printer"]() # Hello!

В данном примере, мы создали пустой словарь registry, простую функцию print_hello, вызов которой приводит к печати слова Hello! в стандартный поток вывода, и продекорировали эту функцию нашим декоратором. В результате поведение нашей функции никак не изменилась, а в словаре появилась запись с нашей функцией. Об этом говорит тот факт, что функциональный вызов элемента словаря с ключом "hello-printer" приводит к тем же результатам, что и непосредственный вызов print_hello.

Итак, на данном этапе у нас есть некоторое публичное API, оно описано, а значит мы готовы присвоить версию нашей библиотеки. Но какую? Присвоенная версия будет напрямую отражать наше намерение. Мы можем присвоить версию 1.0.0. В этом случае мы явно сообщаем пользователям, что мы выпускаем стабильный продуктовый код, которые они могут использовать в своих приложениях без каких-либо опасений. Если же мы не уверены в стабильности нашего кода, и считаем, что он находится только в процессе разработки, а его использование должно происходить на страх и риск разработчиков, то рекомендуется выбрать версию, начинающуюся с 0, например 0.1.0. Ее мы и выберем. Итак, на данном этапе у нас есть библиотека Registrant версии 0.1.0. Менять код выпущенной версии нельзя, поскольку на нее могут полагаться другие разработчики. Поэтому все дальнейшие изменения должны оформляться в виде новых версий библиотек. Приступим к выпуску новой версии.

Патчи

При детальном рассмотрении кода нашей библиотеки можно понять, что он далек от идеала. Одна из неоптимальных вещей, которая пробралась в наш код — это использование f-строки в отладочном сообщении. Проблема в том, что f-строка будет гарантированно вычислена в момент выполнения программы. Модуль logging же будет печать лог только в том случае, если данный лог имеет уровень не ниже уровня логирования, установленного в программе. Т.е. если пользователь установит уровень логирования выше уровня debug, то сообщение напечатано не будет. Но f-строка гарантирует, что сообщение будет составлено всегда. Не очень хорошо. Давайте это исправим, используя форматирование в стиле С, которое делегирует составление текста лога модулю logging.

...  def register(     registry: dict[Hashable, Any],     key: Hashable, ) -> Callable[[T], T]:     def wrapper(obj: T) -> T:         registry[key] = obj         logging.debug("save <%s: %s> into registry", key, obj)         return obj      return wrapper

Отлично. Мы поправили неоптимальность и готовы выпустить новую версию. Но какой номер версии нам стоит использовать в этом случае? Напоминаю, что версия тесно связана с API. Давайте подумаем, как наши изменения влияют на API нашей библиотеки? Изменился ли формат входных или выходных данных? Изменился ли набор исключений? Добавился ли новый функционал? Или мы прекратили поддержку старого функционала? Ничего из этого не произошло. Мы просто выполнили оптимизацию функционала, которая никак не меняет юзкейсы библиотеки. Явно пользователь никак внесенные изменения не заметит.

Такие изменения должны выпускаться в формате патча. За патч отвечает крайнее справа число в записи версии. Т.е. если версия библиотеки — X.Y.Z, то номер патча обозначен числом Z. После выпуска очередного патча, число Z должно увеличиваться на 1. Поскольку версия нашей библиотеки 0.1.0, то после релиза наших обновлений, версия библиотеки должна измениться на 0.1.1.

Минорные версии

Итак, наша библиотека Registrant версии 0.1.1 набирает популярность. Но некоторые пользователи, жалуются, что им постоянно приходится указывать key, даже тогда, когда в качестве ключа можно было бы использовать значение атрибута __name__ объекта. Мы услышали их просьбы, и решили добавить параметризованный декоратор register_with_default_name. Декоратор будет работать точно так же, как и декоратор register. Единственное отличие будет заключаться в том, что новый декоратор будет принимать на вход только один аргумент — registry, а в качестве ключа для сохранения будет использоваться значение obj.__name__. Выглядеть это будет как-то так:

...  def register_with_default_name(     registry: dict[Hashable, Any], ) -> Callable[[T], T]:     def wrapper(obj: T) -> T:         key = obj.__name__         registry[key] = obj         logging.debug("save <%s: %s> into registry", key, obj)         return obj      return wrapper

Опишем юзкейс нашего нового функционала:

registry = {}  @register_with_default_name(registry) def print_hello() -> None:     print("Hello!")  print_hello() # Hello! registry["print_hello"]() # Hello!

Теперь мы предоставили пользователям функционал, для регистрации объектов в словаре без указания ключа. В данном случае в качестве ключа будет использован атрибут объекта __name__, который хранит имя объекта в формате строки. Именно это и иллюстрирует данный пример. При использовании декоратора, мы указываем только словарь, в который нужно сохранить декорируемый объект. Объект будет сохранен под своим же именем, в данном случае функция print_hello сохраняется в словарь по ключу "print_hello".

Теперь публичный API нашей библиотеки пополнился новым декоратором, т.е. был изменен. Эти изменения не являются патчем. Нарушают ли наши изменения обратную совместимость? Если текущие пользователи нашей библиотеки обновятся до новой версии, придется ли им судорожно переписывать код, использующий декоратор register из версии 0.1.1? Нет. Мы никак не нарушили обратную совместимость, но дополнили функционал библиотеки, тем самым изменив публичный API.

В данном случае мы внесли минорные изменения, а потому новая версия должна иметь измененное значение минорной версии библиотеки в числовой записи. Значению минорной версии библиотеки под версией X.Y.Z соответствует число Y. При обновлении минорной версии кода, необходимо увеличить число Y на 1, а число Z — занулить. Т.е. после релиза наших последних изменений, мы будем иметь библиотеку Registrant версии 0.2.0.

Устаревание API

После последнего релиза нашей библиотеки пользователи разделились на два лагеря. Одна группа пользователей рада новому функционалу и начала его активно использовать. Другая группа пользователей негодует от того, что мы выпустили отдельный декоратор для использования имени объекта в качестве ключа, а не сделали это поведение поведением по умолчанию существующего декоратора. Сначала первая группа пользователей была более многочисленна, но со временем недовольство таким решение начало расти, и количество сторонников второй группы кратно увеличилось.

Итак, мы решили объединить наши декораторы в один общий декоратор. Но прежде чем удалять ненужный декоратор, стоит донести до пользователей, что определенный функционал является устаревшим, и его поддержка скоро будет прекращена. Для этого Том Престон-Вернер рекомендует выпускать отдельный релиз с изменением минорной версии, в котором отдельно помечены устаревающие объекты. Так мы и поступим. Выпустим версию 0.3.0, в которой декоратор register_with_default_name помечен как устаревший.

Мажорные версии

Наступил день X, и мы готовы избавиться от устаревшего функционала. Объединим наши декораторы в один декоратор следующим образом:

... _sentinel = object()   def register(     registry: dict[Hashable, Any],     *,     key: Hashable = _sentinel, ) -> Callable[[T], T]:     def wrapper(obj: T) -> T:         nonlocal key          if key is _sentinel:             key = obj.__name__          registry[key] = obj         logging.debug("save <%s: %s> into registry", key, obj)         return obj      return wrapper

Здесь мы сделали пару серьезных изменений. Первое, обращаем внимание на * в аргументах параметризованного декоратора. Все аргументы, которые расположены следом за * будут поддерживать только строго именованный формат передачи значений. Это значит, что аргумент key теперь может быть передан только следующим образом: register(..., key=...). Т.е. мы обязаны явно связывать идентификатор key с его значением, иначе интерпретатор возбудит исключение.

Следующее изменение — это значение по умолчанию для аргумента key. Поскольку нашей изначальной задачей было реализовать поведение по умолчанию, нам требуется некоторое значение, при котором бы мы использовали это самое поведение. Таким образом использование дефолтного поведения завязано на значении по умолчанию аргумента key. Но что использовать в качестве этого значения по умолчанию? key может быть любым хешируемым объектом. None является хешируемым объектом, а значит использование None в качестве значения по умолчанию сделает невозможным использовать None в качестве ключа, что мы позволяли делать раньше. Чтобы не урезать функциональность, в качестве значения по умолчанию мы будем использовать специальный сигнальный объект _sentinel. Это объект-синглтон типа object, который мы не включаем в публичный интерфейс нашей библиотеки и определяем исключительно для внутреннего пользования.

Сам код декоратора — объединение кода декораторов из предыдущей версии библиотеки. Если в key было передано какое-либо значение, мы используем переданное значение в качестве ключа. Иначе в качестве ключа используется имя объекта. nonlocal в теле декоратора появился из-за особенностей обработки интерпретатором оператора присваивания для нелокальных переменных, но об этом уже в другой раз.

Также в этой версии библиотеки мы удалим устаревший декоратор register_with_default_name. Теперь юзкейсы нашей библиотеки можно описать так:

registry = {}  @register(registry) def print_hello() -> None:     print("Hello!")   @register(registry, key="test") def print_test() -> None:     print("test")  print_hello() # Hello! registry["print_hello"]() # Hello!  print_test() # test registry["test"]() # test

Таким образом мы объединили функционал наших декораторов и удалили устаревший функционал. API наше библиотеки теперь стоит исключительно из декоратора register. Мы готовы к релизу. Какая же версия будет у библиотеки после внесения изменений? Поскольку версии тесно связаны с API, мы опять задаемся вопросом, что мы изменили в API, и нарушена ли обратная совместимость? Обратная совместимость нарушена. Во-первых, мы удалили устаревший функционал. Но на это можно было бы закрыть глаза, ведь функционал был помечен как устаревший. Но мы также сделали параметр key декоратора register строго именованным и необязательным. Это значит, что варианты использования этого декоратора вида @register(registry, "test") теперь невалидны. Т.е. обратная совместимость нарушена.

В таких случаях происходит изменение первого числа в записи версии. Такое изменение также называется изменением мажорной версии. Если версия записана в формате X.Y.Z, то мажорная версия соответствует числу X. Изменение мажорной версии увеличивает X на 1 и обнуляет значения минорной версии и патча. Т.е. в нашем случае после выпуска изменений версия должна быть изменена на 1.0.0.

Однако, тут все зависит от наших намерений. Напомню, что предыдущая версия нашей библиотеки — 0.3.0. Мажорная версия 0 не дает никаких гарантий относительно API, и фактически, если мы не готовы к серьезному релизу, мы можем по-прежнему оставаться в мажорной версии 0. Однако, если наша библиотека активно используется в продуктовом коде, тогда, конечно, имеет смысл делать выпуск под версией 1.0.0.

Заключение

Таким образом версия конкретного кода может очень много сказать о намерении автора. Версия напрямую коррелирует с состоянием публичного API и зависит от него. Также версия может много сказать о конкретном этапе разработки и гарантиях, которые авторы кода предоставляют пользователям.

Наверняка данный текст не лишен недостатков и неточностей, поэтому буду рад любым замечаниям. Также в данном тексте не обсуждается вопрос включения метаданных и информации о предрелизной версии. Все эти данные интересующиеся могут найти в Спецификации Семантического Версионирования.

P.S. Также приглашаю вас в свой канал, где я пишу небольшие заметки про Python и разработку.


ссылка на оригинал статьи https://habr.com/ru/articles/873142/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *