From 1773f7d6e97d5f1f413e3520944d7d8fe8503ef6 Mon Sep 17 00:00:00 2001 From: George Fu Date: Wed, 11 Nov 2026 21:11:08 +0000 Subject: [PATCH] Check version consistency from the source tree --- scripts/verifications/version_consistency.py | 125 ++++++++++++++----- 1 file changed, 93 insertions(+), 32 deletions(-) diff --git a/scripts/verifications/version_consistency.py b/scripts/verifications/version_consistency.py index 485dff7b9..24702175c 100644 --- a/scripts/verifications/version_consistency.py +++ b/scripts/verifications/version_consistency.py @@ -7,12 +7,15 @@ gates read; the distribution version is what resolvers and PyPI read. If they disagree, telemetry and support misattribute behavior to the wrong release. The version should also come from one place, sourced the same way in every package. + +Everything is read from the source tree, because installed metadata lags the +source whenever an editable install isn't rebuilt after a version bump. """ -import importlib -import importlib.metadata +import ast +from pathlib import Path -from _common import Finding, main, packages +from _common import Finding, Package, main, packages def check() -> list[Finding]: @@ -21,10 +24,9 @@ def check() -> list[Finding]: pyproject = package.dir / "pyproject.toml" project = package.pyproject["project"] dynamic = "version" in project.get("dynamic", []) - # maturin only reads a static version, so __version__ comes from - # metadata. The runtime comparison below still keeps it truthful. - static_ok = package.maturin - if not dynamic and not static_ok: + if package.maturin: + findings.extend(_check_static(package, str(project.get("version")))) + elif not dynamic: findings.append( Finding( f"{package.dist}: static version {project.get('version')!r} in pyproject.toml; " @@ -32,41 +34,100 @@ def check() -> list[Finding]: pyproject, ) ) - elif dynamic and package.version_file is None: + else: + findings.extend(_check_dynamic(package)) + return findings + + +def _check_dynamic(package: Package) -> list[Finding]: + """Hatch reads the version file, so every root must agree with its literal.""" + pyproject = package.dir / "pyproject.toml" + version_file = package.version_file + if version_file is None: + return [ + Finding( + f"{package.dist}: dynamic version without a hatch version path", + pyproject, + ) + ] + if not version_file.exists(): + return [ + Finding(f"{package.dist}: hatch version path does not exist", pyproject) + ] + expected = _literal_version(version_file) + if expected is None: + return [ + Finding( + f"{package.dist}: hatch version path has no literal __version__", + version_file, + ) + ] + findings: list[Finding] = [] + for root in package.roots: + init = package.src / root / "__init__.py" + found = _literal_version(init) + if found is None: + findings.append(Finding(f"{root} defines no literal __version__", init)) + elif found != expected: findings.append( Finding( - f"{package.dist}: dynamic version without a hatch version path", - pyproject, + f"{root}.__version__ is {found!r}, distribution version is {expected!r}", + init, ) ) - if package.source_version is None and not static_ok: + return findings + + +def _check_static(package: Package, expected: str) -> list[Finding]: + """maturin reads only a static version, so __version__ may defer to its metadata.""" + findings: list[Finding] = [] + for root in package.roots: + init = package.src / root / "__init__.py" + value = _version_value(init) + if value is None: + findings.append(Finding(f"{root} defines no __version__", init)) + elif _reads_own_metadata(value, package.dist): + continue + elif not (isinstance(value, ast.Constant) and value.value == expected): findings.append( Finding( - f"{package.dist}: __version__ is not a literal in the package source", - pyproject, - severity="warning", + f"{root}.__version__ does not match the static version {expected!r}", + init, ) ) - try: - installed = importlib.metadata.version(package.dist) - except importlib.metadata.PackageNotFoundError: - findings.append(Finding(f"{package.dist}: not installed", pyproject)) - continue - for root in package.roots: - runtime = getattr(importlib.import_module(root), "__version__", None) - if runtime is None: - findings.append( - Finding(f"{root} defines no __version__", package.src / root) - ) - elif runtime != installed: - findings.append( - Finding( - f"{root}.__version__ is {runtime!r}, distribution version is {installed!r}", - package.src / root / "__init__.py", - ) - ) return findings +def _version_value(path: Path) -> ast.expr | None: + if not path.exists(): + return None + for node in ast.parse(path.read_text()).body: + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "__version__" for t in node.targets + ): + return node.value + return None + + +def _literal_version(path: Path) -> str | None: + value = _version_value(path) + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return value.value + return None + + +def _reads_own_metadata(value: ast.expr, dist: str) -> bool: + """Whether value is ``version("")``, the distribution's own metadata.""" + return ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name | ast.Attribute) + and (value.func.id if isinstance(value.func, ast.Name) else value.func.attr) + == "version" + and len(value.args) == 1 + and isinstance(value.args[0], ast.Constant) + and value.args[0].value == dist + ) + + if __name__ == "__main__": main("version consistency", check)