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
This commit is contained in:
parent
b8ceee39c5
commit
4100a7f3f5
6 changed files with 647 additions and 3 deletions
11
README.md
11
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.
|
all merges are made with explicit and *empty* merge commits.
|
||||||
This ensures that past branches remain visible in the logs,
|
This ensures that past branches remain visible in the logs,
|
||||||
for example, with `git log --graph`.
|
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).
|
||||||
|
|
|
@ -152,8 +152,10 @@ def test(session: nox.Session) -> None:
|
||||||
install_unpinned(session, "-e", ".") # "-e" makes session reuseable
|
install_unpinned(session, "-e", ".") # "-e" makes session reuseable
|
||||||
install_pinned(
|
install_pinned(
|
||||||
session,
|
session,
|
||||||
|
"packaging",
|
||||||
"pytest",
|
"pytest",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
|
"semver",
|
||||||
)
|
)
|
||||||
|
|
||||||
args = session.posargs or (
|
args = session.posargs or (
|
||||||
|
@ -162,8 +164,7 @@ def test(session: nox.Session) -> None:
|
||||||
TESTS_LOCATION,
|
TESTS_LOCATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Code 5 is temporary as long as there are no tests
|
session.run("pytest", *args)
|
||||||
session.run("pytest", *args, success_codes=[0, 5])
|
|
||||||
|
|
||||||
|
|
||||||
def start(session: nox.Session) -> None:
|
def start(session: nox.Session) -> None:
|
||||||
|
|
13
poetry.lock
generated
13
poetry.lock
generated
|
@ -936,6 +936,17 @@ files = [
|
||||||
{file = "ruff-0.6.4.tar.gz", hash = "sha256:ac3b5bfbee99973f80aa1b7cbd1c9cbce200883bdd067300c22a6cc1c7fba212"},
|
{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]]
|
[[package]]
|
||||||
name = "setuptools"
|
name = "setuptools"
|
||||||
version = "74.1.2"
|
version = "74.1.2"
|
||||||
|
@ -1006,4 +1017,4 @@ files = [
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.9"
|
python-versions = "^3.9"
|
||||||
content-hash = "d57341979796164059f8d01fd2cc367cc21f1000a91b7d96fb02c6319a9b012b"
|
content-hash = "4ba46548b380fe7c34cf4ed48492253b2d3a1866f86c8d8bf569eb18829281c2"
|
||||||
|
|
|
@ -60,8 +60,11 @@ pydoclint = { extras = ["flake8"], version = "^0.5" }
|
||||||
ruff = "^0.6"
|
ruff = "^0.6"
|
||||||
|
|
||||||
# Test suite
|
# Test suite
|
||||||
|
packaging = "^24.1" # to test the version identifier
|
||||||
pytest = "^8.3"
|
pytest = "^8.3"
|
||||||
pytest-cov = "^5.0"
|
pytest-cov = "^5.0"
|
||||||
|
semver = "^3.0" # to test the version identifier
|
||||||
|
tomli = [ { python = "<3.11", version = "^2.0" } ]
|
||||||
|
|
||||||
[tool.poetry.urls]
|
[tool.poetry.urls]
|
||||||
|
|
||||||
|
@ -165,6 +168,11 @@ extend-ignore = [ # never check the following codes
|
||||||
|
|
||||||
per-file-ignores = [
|
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
|
# Explicitly set mccabe's maximum complexity to 10 as recommended by
|
||||||
|
@ -253,6 +261,9 @@ cache_dir = ".cache/mypy"
|
||||||
module = [
|
module = [
|
||||||
"nox",
|
"nox",
|
||||||
"nox_poetry",
|
"nox_poetry",
|
||||||
|
"pytest",
|
||||||
|
"semver",
|
||||||
|
"tomli",
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
@ -362,6 +373,11 @@ known-first-party = ["lalib"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[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]
|
[tool.ruff.lint.pycodestyle]
|
||||||
|
|
|
@ -1 +1,19 @@
|
||||||
"""A Python library to study linear algebra."""
|
"""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
|
||||||
|
|
587
tests/test_version.py
Normal file
587
tests/test_version.py
Normal file
|
@ -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"
|
Loading…
Reference in a new issue