0

I am the maintainer of a Python library that uses semantic versioning. Skip the next paragraph if you are familiar with what it means.

The rough and dirty explanation of semantic versioning is as follows. The library developer uses a version number of the form MAJOR.MINOR.PATCH where the first two components are incremental integers; the major version number is bumped if and only if the new version broke backward compatibility, and the minor version number is bumped if and only if the new version breaks forward compatibility. (The last component is at least one integer, but can be more complex - build ID etc.) In other words, using Python version specification syntax, a downstream library that works with version x.y.z of your library should be guaranteed to work with any version >=x.y,<(x+1), and the downstream maintainer does not need to test against all such versions. Note that the downstream maintainer is only allowed to rely on the public API - any undocumented behavior is subject to change without notice; in particular, Python functions/classes/methods with a name that starts with an underscore are considered internal to the library (you can technically use them elsewhere, but you should not).

In general, there can be reasonable disagreements between some people about what constitutes a "change to the public API" (as well as unreasonable disagreements). In my particular case, I am not extremely concerned; we have not yet encountered a genuine disagreement over whether a given change is semver-major or not in five human-years of development.

Right now, we are setting version numbers manually. I wish to detect whether a given change needs to bump the major version, minor version, or patch, in a manner as automated as possible. (If you know a tool that does this, I am interested. setuptools-scm or derivatives do not fit the bill, because those tools get their information from the git tags, which must be placed manually by a human. If you provide a tool, I am also interested in an explanation of how it works and whether it differs from my approach, below.) The result of that analysis would be used by either a git hook or CI pipeline to bump the version number as appropriate, with a possibility for the developer to override that decision (to be discussed during merge review).

I am thinking to code something along the following lines:

api_before = parse_api(files_before)
api_after = parse_api(files_after)

if api_before == api_after:
    return "patch change"
elif api_before is a subset of api_after:
    return "minor version change"
else:
    return "major version change"

There’s some footwork to code parse_api, but I do not foresee any conceptual difficulty. I expect to get it running using the inspect module (which can for example get the signature of a callable object) and/or looking at type hints via the ast module. In my context, we are using the CPython reference implementation, and between the linter and code reviews I have reasonable confidence that type hints will be correct.

To demonstrate how it works, consider a minimal library:

class FooBar(object):
    def foo(self) -> str:
        return "foo"
    def _private_function(self) -> int:
        return 1

The pseudocode correctly identifies the following changes as patch, minor and major:

# patch: no change in public APIs, only private functions
class FooBar(object):
    def foo(self) -> str:
        return "foo"
    def _private(self) -> str:
        return "1"
    def _another_private_function(self) -> float:
        return 3.14
# minor: a new method is created
class FooBar(object):
    def foo(self) -> str:
        return "foo"
    def bar(self) -> str:
        return "bar"
    def _private(self) -> int:
        return 1
# major: an existing method is modified (FooBar.foo now returns an int)
class FooBar(object):
    def foo(self) -> int:
        return 1
    def bar(self) -> str:
        return "bar"
    def _private(self) -> int:
        return 1

I am aware that the pseudocode will fail in certain cases. In particular, any change affecting the handling of input/output values but not their type will not be picked up, such as:

# major change: we change the return value hence the external behavior
# but identified as patch by the pseudocode because there are no change in function/class/method names or their signatures
class FooBar(object):
    def foo(self) -> str:
        return "bar"  # instead of "foo"
    def _private(self) -> int:
        return 1

This would also include code that parses *args/**kwargs, changes to in-library config files, etc. However, many of these should be caught by unit tests and/or code review, and I am ready to live with the rest.

Are there other cases where the pseudocode incorrectly estimates the importance of the change?

In particular, are there cases where the pseudocode overestimates the version bump needed? I believe not; if it tells you it’s a minor version change, it must be at least a minor version change - maybe it’s major because it missed something, but it is definitely not a patch.

3
  • 1
    BTW, that XKCD comic is one of my favorites! Jul 12, 2023 at 18:25
  • 1
    I'm having trouble finding the question here. Tool recommendations are off-topic in this community. Asking if there are "other cases where the pseudocode incorrectly estimates the importance of the change" is too open-ended for us to provide a comprehensive answer. Is there a particular aspect of this design you would like help with, besides identifying the 1,001 ways it could fail in unforeseeable edge cases? Jul 12, 2023 at 18:31
  • I feel I am reinventing the wheel, so I am showing the wheel’s blueprints before I start manufacturing it. I don’t care much about quirky edge cases (such as: the wheel fails to meet Nepal’s standards of road safety when temperature drops below -20°C). However, if the wheel is square when it should be round, I would hope someone would mention it. If there’s no visible problems, then that’s an answer.
    – JMU
    Jul 17, 2023 at 15:40

1 Answer 1

2

You could look into Conventional Commits. The idea here is that each commit message contains the information needed to determine the kind of version bump that is desired.

This is not a fully automated system, because it still requires a human to write the commit messages in the correct format and with the right information, that human only has to consider what is part of the current commit they are making.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.