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

Какое-то время подходы к версионированию варьировались от компании к компании, до тех пор, пока соучредитель 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 и разработку.

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


  1. Tomasina
    12.01.2025 19:35

    Четко и по сути. Благодарю.


  1. geher
    12.01.2025 19:35

    Сейчас набирает аопулярность другой принцип нумерации версий, опирающийся на год выпуска. Вторая цифра означает либо месяц, либо номер какой-то части года (полугодие, квартал), либо порядковый номер версии в течении года.

    Используется, например, в Libreoffice и 7-zip.

    Уже и до библиотек этот способ добрался (lzma)


    1. Cykooz
      12.01.2025 19:35

      Как правило такой способ используется для приложений, которые не создают ни какие артефакты или формат этих артефактов не зависит от версии приложения. Например формат odt, 7-zip и др. определяется отдельной спецификацией, которую не может изменить автор(ы) приложения.

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


      1. cupraer
        12.01.2025 19:35

        Надо быть очень уверенным в том, что API библиотеки не поменяется никогда в будущем.

        API библиотеки, пригодной к использованию, не может меняться в обе стороны, только в сторону расширения. Обратная совместимость — наиглавнейший параметр при любом изменении. Если библиотека не совместима с предыдущими версиями — место ей в мусорном ведре.


        1. Cykooz
          12.01.2025 19:35

          Это слишком радикально.
          Правильнее вот так: Если автор сломал обратную совместимость - это его дело, вероятно так будет лучше. Но если он при этом не продолжает исправлять критические ошибки в прошлой версии - место ему в мусорном ведре :-)


          1. cupraer
            12.01.2025 19:35

            Ладно, так тоже нормально :)

            Но это еще менее реалистично, чем поддерживать обратную совместимость — как автор и мейнтейнер 10+ библиотек говорю. В принципе, еще туда-сюда поломать API при выходе из мажорной версии 0 в 1 (хотя я себе и такое не позволяю).

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


            1. Cykooz
              12.01.2025 19:35

              Из-за такой мотивации кучу библиотек десятилетиями не вылазят из версии 0.х. Всё думают про Васю, который может очень расстроится если через год после версии 1.0 выйдет версия 2.0. Лучше уж дождаться момента, когда просто не захочется или не будет возможности вносить улучшения в библиотеку. Тогда и выпустить 1.0.


              1. cupraer
                12.01.2025 19:35

                Я ровно так и делаю. И после 1.0.0 — уже только фиксы.


      1. geher
        12.01.2025 19:35

        Например формат odt, 7-zip и др. определяется отдельной спецификацией, которую не может изменить автор(ы) приложения.

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

        В случае 7z уже достигнутв ситуация, когда сиарые версии архиватора не всегда могут открыть файл, созданный новой версией арзиватора (появился новый алгоритм сжатия - LZMA2)


    1. mixtyraa
      12.01.2025 19:35

      Описанный вами подход называется calver — Calendar Versioning. В статье рассматривается semver — Semantic Versioning.

      Я использую calver в приложениях, потому что клиентам не важно знать, ломается обратная совместимость или нет.

      В библиотеках обратная ситуация: важно понимать, сломается ли обратная совместимость при обновлении, а вместе с ней — и полпроекта. Semver даёт такое понимание, но в реальности всё зависит от ответственности разработчика библиотеки.


  1. Germanjon
    12.01.2025 19:35

    Хорошо бы добавить TL DR или подитог, чтобы кратко определить рекомендацию к различиям между версиями.


    1. kinh
      12.01.2025 19:35

      Если кратко. Версия, вообще говоря, состоит из четырёх чисел, например: 1.2.3.4
      Эти числа носят следующие названия в порядке слева направо: 1 - Major number, 2 - Minor number, 3 - Patch number, 4 - Build number.
      Если отличается Major, то это означает наиболее радикальные отличия. Либо формат данных полностью изменился, либо полностью изменился пользовательский интерфейс. В общем, на новую программу придётся полностью переучиваться.
      Если отличается Minor, то это, как правило, означает лишь расширение функционала существующей программы. Формат данных дополнился новыми полями, в интерфейсе пользователя появились новые пункты меню, новые виджеты. Однако пользователь, знакомый с предыдущей версией, без труда разберётся с новой, так как версии схожи между собой.
      Если отличается Patch number, то это означает исправление ошибок в программе, без изменения интерфейса.
      Последний - Build number, используется только разработчиком программы, например так: программист создаёт программу, присваивает ей очередной этот номер, затем программу проверяют на ошибки, если обнаруживают, то исправляют, и присваивают следующий номер. Либо добавляют функционал, и тоже увеличивают этот номер. Но все эти циклы происходят внутри компании, и чтобы не сбивать с толку конечных пользователей, изменения пишут в этот номер, чтобы потом, при распространении программы, просто его убрать.

      Вообще говоря, запись версии через точку оказалась неудачной идеей, так как создаёт путаницу с записью вещественных чисел. В результате, возникает неопределённость, какой номер версии более новый: 1.2 или 1.101
      Каждая компания интерпретирует это по-своему.


      1. Germanjon
        12.01.2025 19:35

        Благодарю. Примерно этого я хотел добиться от автора, чтобы он добавил в пост. И читающим, не знакомым с темой, было проще понять.
        PS. У нас в компании при разработке софта используются примерно те же правила нумерации.

        При разработке документации правила следующие:
        - Major версия - изменение набора функционала, который не имеет обратной совместимости с предыдущей версией.
        - Minor версия - добавление новых методов или параметров, при этом обратная совместимость сохраняется.
        - Fix версия - смысловых изменений нет, исправления в основном носят редакционный характер (добавляется/расширяется описание, появляются уточнения).