From 4100a7f3f54688544626969c08ee7d22562189ad Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Tue, 10 Sep 2024 01:45:47 +0200 Subject: [PATCH] Add `__version__` identifier - `lalib.__version__` is dynamically assigned - the format is "x.y.z[.dev0|aN|bN|rcN|.postM]" where x, y, z, M, and N are non-negative integers + x, y, and z model the "major", "minor", and "patch" parts of semantic versioning (See: https://semver.org/#semantic-versioning-200) + M is a single digit and N either 1 or 2 => This complies with (a strict subset of) PEP440 - add unit tests for the `__version__` identifier --- README.md | 11 + noxfile.py | 5 +- poetry.lock | 13 +- pyproject.toml | 16 ++ src/lalib/__init__.py | 18 ++ tests/test_version.py | 587 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 647 insertions(+), 3 deletions(-) create mode 100644 tests/test_version.py diff --git a/README.md b/README.md index 513f168..a3b794e 100644 --- a/README.md +++ b/README.md @@ -119,3 +119,14 @@ Whereas a rebase makes a simple fast-forward merge possible, all merges are made with explicit and *empty* merge commits. This ensures that past branches remain visible in the logs, for example, with `git log --graph`. + + +#### Versioning + +The version identifiers adhere to a subset of the rules in + [PEP440](https://peps.python.org/pep-0440/) and + follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +So, releases to [PyPI](https://pypi.org/) + come in the popular `major.minor.patch` format. +The specific rules for this project are explained + [here](https://github.com/webartifex/lalib/blob/main/tests/test_version.py). diff --git a/noxfile.py b/noxfile.py index 26e5f36..63f462a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -152,8 +152,10 @@ def test(session: nox.Session) -> None: install_unpinned(session, "-e", ".") # "-e" makes session reuseable install_pinned( session, + "packaging", "pytest", "pytest-cov", + "semver", ) args = session.posargs or ( @@ -162,8 +164,7 @@ def test(session: nox.Session) -> None: TESTS_LOCATION, ) - # Code 5 is temporary as long as there are no tests - session.run("pytest", *args, success_codes=[0, 5]) + session.run("pytest", *args) def start(session: nox.Session) -> None: diff --git a/poetry.lock b/poetry.lock index fc9d3af..accb356 100644 --- a/poetry.lock +++ b/poetry.lock @@ -936,6 +936,17 @@ files = [ {file = "ruff-0.6.4.tar.gz", hash = "sha256:ac3b5bfbee99973f80aa1b7cbd1c9cbce200883bdd067300c22a6cc1c7fba212"}, ] +[[package]] +name = "semver" +version = "3.0.2" +description = "Python helper for Semantic Versioning (https://semver.org)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "semver-3.0.2-py3-none-any.whl", hash = "sha256:b1ea4686fe70b981f85359eda33199d60c53964284e0cfb4977d243e37cf4bf4"}, + {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, +] + [[package]] name = "setuptools" version = "74.1.2" @@ -1006,4 +1017,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "d57341979796164059f8d01fd2cc367cc21f1000a91b7d96fb02c6319a9b012b" +content-hash = "4ba46548b380fe7c34cf4ed48492253b2d3a1866f86c8d8bf569eb18829281c2" diff --git a/pyproject.toml b/pyproject.toml index 64c1a27..762c1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,8 +60,11 @@ pydoclint = { extras = ["flake8"], version = "^0.5" } ruff = "^0.6" # Test suite +packaging = "^24.1" # to test the version identifier pytest = "^8.3" pytest-cov = "^5.0" +semver = "^3.0" # to test the version identifier +tomli = [ { python = "<3.11", version = "^2.0" } ] [tool.poetry.urls] @@ -165,6 +168,11 @@ extend-ignore = [ # never check the following codes per-file-ignores = [ + # Linting rules for the test suite: + # - type hints are not required + # - `assert`s are normal + "tests/*.py:ANN,S101", + ] # Explicitly set mccabe's maximum complexity to 10 as recommended by @@ -253,6 +261,9 @@ cache_dir = ".cache/mypy" module = [ "nox", "nox_poetry", + "pytest", + "semver", + "tomli", ] ignore_missing_imports = true @@ -362,6 +373,11 @@ known-first-party = ["lalib"] [tool.ruff.lint.per-file-ignores] +"tests/*.py" = [ # Linting rules for the test suite: + "ANN", # - type hints are not required + "S101", # - `assert`s are normal + "W505", # - docstrings may be longer than 72 characters +] [tool.ruff.lint.pycodestyle] diff --git a/src/lalib/__init__.py b/src/lalib/__init__.py index 50db320..53f0508 100644 --- a/src/lalib/__init__.py +++ b/src/lalib/__init__.py @@ -1 +1,19 @@ """A Python library to study linear algebra.""" + +from importlib import metadata + + +try: + pkg_info = metadata.metadata(__name__) + +except metadata.PackageNotFoundError: + __pkg_name__ = "unknown" + __version__ = "unknown" + +else: + __pkg_name__ = pkg_info["name"] + __version__ = pkg_info["version"] + del pkg_info + + +del metadata diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..be02fae --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,587 @@ +"""Test the package's version identifier. + +The packaged version identifier (i.e., `lalib.__version__`) +adheres to PEP440 and its base part follows semantic versioning: + + - In general, version identifiers follow the "x.y.z" format + where x, y, and z are non-negative integers (e.g., "0.1.0" + or "1.2.3") matching the "major", "minor", and "patch" parts + of semantic versioning + + - Without suffixes, these "x.y.z" versions represent + the ordinary releases to PyPI + + - Developmental or non-release versions are indicated + with a ".dev0" suffix; we use solely a "0" to keep things simple + + - Pre-releases come as "alpha", "beta", and "release candidate" + versions, indicated with "aN", "bN", and "rcN" suffixes + (no "." separator before the suffixes) where N is either 1 or 2 + + - Post-releases are possible and indicated with a ".postN" suffix + where N is between 0 and 9; they must not change the code + as compared to their corresponding ordinary release version + +Examples: + - "0.4.2" => ordinary release; public API may be unstable + as explained by the rules of semantic versioning + - "1.2.3" => ordinary release (long-term stable versions) + - "4.5.6.dev0" => early development stage before release "4.5.6" + - "4.5.6a1" => pre-release shortly before publication of "4.5.6" + +Sources: + - https://peps.python.org/pep-0440/ + - https://semver.org/spec/v2.0.0.html + +Implementation notes: + + - The `packaging` library and the `importlib.metadata` module + are very forgiving when parsing version identifiers + => The test cases in this file enforce a strict style + + - The `DECLARED_VERSION` (in pyproject.toml) and the + `PACKAGED_VERSION` (read from the metadata after installation + in a virtual environment) are tested besides a lot of + example `VALID_VERSIONS` to obtain a high confidence + in the test cases + + - There are two generic kind of test cases: + + + `TestVersionIdentifier` uses the `packaging` and `semver + libraries to parse the various `*_VERSION`s and validate + their infos + + + `TestVersionIdentifierWithPattern` defines a `regex` pattern + comprising all rules at once +""" + +import contextlib +import importlib +import itertools +import pathlib +import re +import string +import sys + +import pytest +import semver +from packaging import version as pkg_version + +import lalib + + +# Support Python 3.9 and 3.10 +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + + +def load_version_from_pyproject_toml(): + """The version declared in pyproject.toml.""" + with pathlib.Path("pyproject.toml").open("rb") as fp: + pyproject_toml = tomllib.load(fp) + + return pyproject_toml["tool"]["poetry"]["version"] + + +def expand_digits_to_versions( + digits=string.digits[1:], + *, + pre_process=(lambda x, y, z: (x, y, z)), + filter_=(lambda _x, _y, _z: True), + post_process=(lambda x, y, z: (x, y, z)), + unique=False, +): + """Yield examplatory semantic versions. + + For example, "12345" is expanded into "1.2.345", "1.23.45", ..., "123.4.5". + + In general, the `digits` are sliced into three parts `x`, `y`, and `z` + that could be thought of the "major", "minor", and "patch" parts of + a version identifier. The `digits` themselves are not re-arranged. + + `pre_process(x, y, z)` transform the parts individually. As an example, + `part % 100` makes each part only use the least significant digits. + So, in the example, "1.2.345" becomes "1.2.45". + + `post_process(x, y, z)` does the same but only after applying the + `filter_(x, y, z)` which signals if the current `x`, `y`, and `z` + should be skipped. + + `unique=True` ensures a produced version identifier is yielded only once. + """ + seen_before = set() if unique else None + + for i in range(1, len(digits) - 1): + for j in range(i + 1, len(digits)): + x, y, z = int(digits[:i]), int(digits[i:j]), int(digits[j:]) + + x, y, z = pre_process(x, y, z) + + if not filter_(x, y, z): + continue + + x, y, z = post_process(x, y, z) + + if unique: + if (x, y, z) in seen_before: + continue + else: + seen_before.add((x, y, z)) + + yield f"{x}.{y}.{z}" + + +DECLARED_VERSION = load_version_from_pyproject_toml() +PACKAGED_VERSION = lalib.__version__ + +VALID_AND_NORMALIZED_VERSIONS = ( + "0.1.0", + "0.1.1", + "0.1.99", + "0.2.0", + "0.99.0", + "1.0.0", + "1.2.3.dev0", + "1.2.3a1", + "1.2.3a2", + "1.2.3b1", + "1.2.3b2", + "1.2.3rc1", + "1.2.3rc2", + "1.2.3", + *(f"1.2.3.post{n}" for n in range(10)), + # e.g., "1.2.89", "1.23.89", "1.78.9", "12.3.89", "12.34.89", and "67.8.9" + *expand_digits_to_versions( + "12345689", + pre_process=(lambda x, y, z: (x % 100, y % 100, z % 100)), + ), +) + +# The `packaging` library can parse the following versions +# that are then normalized according to PEP440 +# Source: https://peps.python.org/pep-0440/#normalization +VALID_AND_NOT_NORMALIZED_VERSIONS = ( + "1.2.3dev0", + "1.2.3-dev0", + "1.2.3_dev0", + "1.2.3alpha1", + "1.2.3.alpha1", + "1.2.3-alpha1", + "1.2.3_alpha1", + "1.2.3.a1", + "1.2.3.a2", + "1.2.3beta1", + "1.2.3.beta1", + "1.2.3-beta1", + "1.2.3_beta1", + "1.2.3.b1", + "1.2.3.b2", + "1.2.3c1", + "1.2.3.c1", + "1.2.3.rc1", + "1.2.3c2", + "1.2.3.c2", + "1.2.3.rc2", + "1.2.3post0", + "1.2.3-post0", + "1.2.3_post0", + "1.2.3-r0", + "1.2.3-rev0", + "1.2.3-0", + "1.2.3_post9", +) + +VALID_VERSIONS = ( + DECLARED_VERSION, + PACKAGED_VERSION, + *VALID_AND_NORMALIZED_VERSIONS, + *VALID_AND_NOT_NORMALIZED_VERSIONS, +) + +# The following persions cannot be parsed by the `packaging` library +INVALID_NOT_READABLE = ( + "-1.2.3", + "+1.2.3", + "!1.2.3", + "x.2.3", + "1.y.3", + "1.2.z", + "x.y.z", + "1.2.3.abc", + "1.2.3.d0", + "1.2.3.develop0", + "1.2.3..dev0", + "1..2.3", + "1.2..3", + "1-2-3", + "1,2,3", +) + +# The `packaging` library is able to parse the following versions +# that however are not considered valid for this project +INVALID_NOT_SEMANTIC = ( + "1", + "1.2", + "01.2.3", + "1.02.3", + "1.2.03", + "1.2.3.4", + "1.2.3.dev-1", + "1.2.3.dev01", + "v1.2.3", +) + +INVALID_VERSIONS = INVALID_NOT_READABLE + INVALID_NOT_SEMANTIC + + +@pytest.mark.parametrize( + ["version1", "version2"], + zip( # loop over pairs of neighboring elements + VALID_AND_NORMALIZED_VERSIONS, + VALID_AND_NORMALIZED_VERSIONS[1:], + ), +) +def test_versions_are_strictly_ordered(version1, version2): + """`VALID_AND_NORMALIZED_VERSIONS` are ordered.""" + version1_parsed = pkg_version.Version(version1) + version2_parsed = pkg_version.Version(version2) + + assert version1_parsed < version2_parsed + + +@pytest.mark.parametrize( + ["version1", "version2"], + zip( # loop over pairs of neighboring elements + VALID_AND_NOT_NORMALIZED_VERSIONS, + VALID_AND_NOT_NORMALIZED_VERSIONS[1:], + ), +) +def test_versions_are_weakly_ordered(version1, version2): + """`VALID_AND_NOT_NORMALIZED_VERSIONS` are ordered.""" + version1_parsed = pkg_version.Version(version1) + version2_parsed = pkg_version.Version(version2) + + assert version1_parsed <= version2_parsed + + +class VersionClassification: + """Classifying version identifiers. + + There are four distinct kinds of version identifiers: + - "X.Y.Z.devN" => developmental non-releases + - "X.Y.Z[aN|bN|rcN]" => pre-releases (e.g., alpha, beta, or release candidates) + - "X.Y.Z" => ordinary (or "official) releases to PypI + - "X.Y.Z.postN" => post-releases (e.g., to add missing non-code artifacts) + + The `packaging` library models these four cases slightly different, and, + most notably in an overlapping fashion (i.e., developmental releases are + also pre-releases). + + The four methods in this class introduce our own logic + treating the four cases in a non-overlapping fashion. + """ + + def is_dev_release(self, parsed_version): + """A "X.Y.Z.devN" release.""" + is_so_by_parts = ( + parsed_version.dev is not None + and parsed_version.pre is None + and parsed_version.post is None + ) + + if is_so_by_parts: + assert parsed_version.is_devrelease is True + assert parsed_version.is_prerelease is True + assert parsed_version.is_postrelease is False + + return is_so_by_parts + + def is_pre_release(self, parsed_version): + """A "X.Y.Z[aN|bN|rcN]" release.""" + is_so_by_parts = ( + parsed_version.dev is None + and parsed_version.pre is not None + and parsed_version.post is None + ) + + if is_so_by_parts: + assert parsed_version.is_devrelease is False + assert parsed_version.is_prerelease is True + assert parsed_version.is_postrelease is False + + return is_so_by_parts + + def is_ordinary_release(self, parsed_version): + """A "X.Y.Z" release.""" + is_so_by_parts = ( + parsed_version.dev is None + and parsed_version.pre is None + and parsed_version.post is None + ) + + if is_so_by_parts: + assert parsed_version.is_devrelease is False + assert parsed_version.is_prerelease is False + assert parsed_version.is_postrelease is False + + return is_so_by_parts + + def is_post_release(self, parsed_version): + """A "X.Y.Z.postN" release.""" + is_so_by_parts = ( + parsed_version.dev is None + and parsed_version.pre is None + and parsed_version.post is not None + ) + + if is_so_by_parts: + assert parsed_version.is_devrelease is False + assert parsed_version.is_prerelease is False + assert parsed_version.is_postrelease is True + + return is_so_by_parts + + +class TestVersionIdentifier(VersionClassification): + """The versions must comply with PEP440 ... + + and follow some additional constraints, most notably that a version's + base complies with semantic versioning. + """ + + def test_packaged_version_is_declared_version(self): + """`lalib.__version__` matches "version" in pyproject.toml exactly.""" + assert PACKAGED_VERSION == DECLARED_VERSION + + @pytest.fixture + def parsed_version(self, request): + """A version identifier parsed with `packaging.version.Version()`.""" + return pkg_version.Version(request.param) + + @pytest.fixture + def unparsed_version(self, request): + """A version identifier represented as an ordinary `str`.""" + return request.param + + @pytest.mark.parametrize("unparsed_version", INVALID_NOT_READABLE) + def test_does_not_follow_pep440(self, unparsed_version): + """A version's base does not follow PEP440.""" + with pytest.raises(pkg_version.InvalidVersion): + pkg_version.Version(unparsed_version) + + @pytest.mark.parametrize( + ["parsed_version", "unparsed_version"], + [(v, v) for v in VALID_VERSIONS], + indirect=True, + ) + def test_base_follows_semantic_versioning(self, parsed_version, unparsed_version): + """A version's base follows semantic versioning.""" + result = semver.Version.parse(parsed_version.base_version) + result = str(result) + + assert unparsed_version.startswith(result) + + @pytest.mark.parametrize("version", INVALID_NOT_READABLE + INVALID_NOT_SEMANTIC) + def test_base_does_not_follow_semantic_versioning(self, version): + """A version's base does not follow semantic versioning.""" + with pytest.raises(ValueError, match="not valid SemVer"): + semver.Version.parse(version) + + @pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True) + def test_has_major_minor_patch_parts(self, parsed_version): + """A version's base consists of three parts.""" + three_parts = 3 + + assert len(parsed_version.release) == three_parts + + @pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True) + @pytest.mark.parametrize("part", ["major", "minor", "micro"]) + def test_major_minor_patch_parts_are_within_range(self, parsed_version, part): + """A version's "major", "minor", and "patch" parts are non-negative and `< 100`.""" + # "micro" in PEP440 is "patch" in semantic versioning + part = getattr(parsed_version, part, -1) + two_digits_only = 100 + + assert 0 <= part < two_digits_only + + @pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True) + def test_is_either_dev_pre_post_or_ordinary_release(self, parsed_version): + """A version is exactly one of four kinds.""" + result = ( # `bool`s behaving like `int`s + self.is_dev_release(parsed_version) + + self.is_pre_release(parsed_version) + + self.is_ordinary_release(parsed_version) + + self.is_post_release(parsed_version) + ) + + assert result == 1 + + @pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True) + def test_dev_releases_come_with_dev0(self, parsed_version): + """A ".devN" version always comes with ".dev0".""" + if self.is_dev_release(parsed_version): + assert parsed_version.dev == 0 + assert parsed_version.pre is None + assert parsed_version.post is None + else: + assert parsed_version.dev is None + + @pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True) + def test_pre_releases_come_with_suffix1_or_suffix2(self, parsed_version): + """A "aN", "bN", or "rcN" version always comes with N as 1 or 2.""" + if self.is_pre_release(parsed_version): + assert parsed_version.dev is None + assert parsed_version.pre[1] in (1, 2) + assert parsed_version.post is None + else: + assert parsed_version.pre is None + + @pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True) + def test_ordinary_releases_have_no_suffixes(self, parsed_version): + """A ordinary release versions has no suffixes.""" + if self.is_ordinary_release(parsed_version): + assert parsed_version.dev is None + assert parsed_version.pre is None + assert parsed_version.post is None + + @pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True) + def test_post_releases_come_with_post0_to_post9(self, parsed_version): + """A ".postN" version always comes with N as 0 through 9.""" + if self.is_post_release(parsed_version): + assert parsed_version.dev is None + assert parsed_version.pre is None + assert parsed_version.post in range(10) + else: + assert parsed_version.post is None + + @pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True) + def test_has_no_epoch_segment(self, parsed_version): + """A version has no epoch segment.""" + assert parsed_version.epoch == 0 + + @pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True) + def test_has_no_local_segment(self, parsed_version): + """A parsed_version has no local segment. + + In semantic versioning, the "local" segment is referred + to as the "build metadata". + """ + assert parsed_version.local is None + + @pytest.mark.parametrize( + ["parsed_version", "unparsed_version"], + [ + (v, v) + for v in ( + DECLARED_VERSION, + PACKAGED_VERSION, + *VALID_AND_NORMALIZED_VERSIONS, + ) + ], + indirect=True, + ) + def test_is_normalized(self, parsed_version, unparsed_version): + """A version is already normalized. + + For example, a version cannot be "1.2.3.a1" + because this gets normalized into "1.2.3a1". + """ + assert parsed_version.public == unparsed_version + + @pytest.mark.parametrize( + ["parsed_version", "unparsed_version"], + [(v, v) for v in VALID_AND_NOT_NORMALIZED_VERSIONS], + indirect=True, + ) + def test_is_not_normalized(self, parsed_version, unparsed_version): + """A version is not yet normalized. + + For example, the version "1.2.3.a1" + gets normalized into "1.2.3a1". + """ + assert parsed_version.public != unparsed_version + + +class TestVersionIdentifierWithPattern: + """Test the versioning with a custom `regex` pattern.""" + + x_y_z_version = r"^(0|([1-9]\d*))\.(0|([1-9]\d*))\.(0|([1-9]\d*))" + suffixes = r"((\.dev0)|(((a)|(b)|(rc))(1|2))|(\.post\d{1}))" + version_pattern = re.compile(f"^{x_y_z_version}{suffixes}?$") + + @pytest.mark.parametrize( + "version", + [ + DECLARED_VERSION, + PACKAGED_VERSION, + ], + ) + def test_packaged_and_declared_version(self, version): + """Packaged version follows PEP440 and semantic versioning.""" + result = self.version_pattern.fullmatch(version) + + assert result is not None + + # The next two test cases are sanity checks to validate the `version_pattern`. + + @pytest.mark.parametrize("version", VALID_AND_NORMALIZED_VERSIONS) + def test_valid_versioning(self, version): + """A version follows the "x.y.z[.devN|aN|bN|rcN|.postN]" format.""" + result = self.version_pattern.fullmatch(version) + + assert result is not None + + @pytest.mark.parametrize("version", INVALID_VERSIONS) + def test_invalid_versioning(self, version): + """A version does not follow the "x.y.z[.devN|aN|bN|rcN|.postN]" format.""" + result = self.version_pattern.fullmatch(version) + + assert result is None + + +class TestUnavailablePackageMetadata: + """Pretend only source files are available, without metadata.""" + + def find_path_to_package_metadata_folder(self, name): + """Find the path to a locally installed package within a `venv`.""" + paths = tuple( + itertools.chain( + *(pathlib.Path(path).glob(f"{name}-*.dist-info/") for path in sys.path), + ), + ) + + # Sanity Check: There must be exactly one folder + # for an installed package within a virtual environment + assert len(paths) == 1 + + return pathlib.Path(paths[0]).relative_to(pathlib.Path.cwd()) + + @contextlib.contextmanager + def hide_metadata_from_package(self, name): + """Hide the metadata of a locally installed package.""" + # Rename the metadata folder + path = self.find_path_to_package_metadata_folder(name) + path.rename(str(path).replace(name, f"{name}.tmp")) + + # (Re-)Load the package with missing metadata + package = importlib.import_module(name) + importlib.reload(package) + + try: + yield package + + finally: + # Restore the original metadata folder + path = self.find_path_to_package_metadata_folder(f"{name}.tmp") + path = path.rename(str(path).replace(f"{name}.tmp", name)) + + # Reload the package with the original metadata for other tests + importlib.reload(package) + + def test_package_without_version_info(self): + """Import `lalib` with no available version info.""" + with self.hide_metadata_from_package("lalib") as lalib_pkg: + assert lalib_pkg.__pkg_name__ == "unknown" + assert lalib_pkg.__version__ == "unknown"