77gates read; the distribution version is what resolvers and PyPI read. If they
88disagree, telemetry and support misattribute behavior to the wrong release. The
99version should also come from one place, sourced the same way in every package.
10+
11+ Everything is read from the source tree, because installed metadata lags the
12+ source whenever an editable install isn't rebuilt after a version bump.
1013"""
1114
12- import importlib
13- import importlib . metadata
15+ import ast
16+ from pathlib import Path
1417
15- from _common import Finding , main , packages
18+ from _common import Finding , Package , main , packages
1619
1720
1821def check () -> list [Finding ]:
@@ -29,41 +32,92 @@ def check() -> list[Finding]:
2932 pyproject ,
3033 )
3134 )
32- elif package .version_file is None :
35+ findings .extend (_check_static (package , str (project .get ("version" ))))
36+ else :
37+ findings .extend (_check_dynamic (package ))
38+ return findings
39+
40+
41+ def _check_dynamic (package : Package ) -> list [Finding ]:
42+ """Hatch reads the version file, so every root must agree with its literal."""
43+ pyproject = package .dir / "pyproject.toml"
44+ version_file = package .version_file
45+ if version_file is None :
46+ return [
47+ Finding (
48+ f"{ package .dist } : dynamic version without a hatch version path" ,
49+ pyproject ,
50+ )
51+ ]
52+ if not version_file .exists ():
53+ return [
54+ Finding (f"{ package .dist } : hatch version path does not exist" , pyproject )
55+ ]
56+ expected = _literal_version (version_file )
57+ if expected is None :
58+ return [
59+ Finding (
60+ f"{ package .dist } : hatch version path has no literal __version__" ,
61+ version_file ,
62+ )
63+ ]
64+ findings : list [Finding ] = []
65+ for root in package .roots :
66+ init = package .src / root / "__init__.py"
67+ found = _literal_version (init )
68+ if found is None :
69+ findings .append (Finding (f"{ root } defines no literal __version__" , init ))
70+ elif found != expected :
3371 findings .append (
3472 Finding (
35- f"{ package . dist } : dynamic version without a hatch version path " ,
36- pyproject ,
73+ f"{ root } .__version__ is { found !r } , distribution version is { expected !r } " ,
74+ init ,
3775 )
3876 )
39- if package .source_version is None :
77+ return findings
78+
79+
80+ def _check_static (package : Package , expected : str ) -> list [Finding ]:
81+ """A static version is a second source, so every root must still agree with it."""
82+ findings : list [Finding ] = []
83+ for root in package .roots :
84+ init = package .src / root / "__init__.py"
85+ found = _literal_version (init )
86+ if found is None :
87+ findings .append (Finding (f"{ root } defines no literal __version__" , init ))
88+ elif found != expected :
4089 findings .append (
4190 Finding (
42- f"{ package .dist } : __version__ is not a literal in the package source" ,
43- pyproject ,
44- severity = "warning" ,
91+ f"{ root } .__version__ is { found !r} , static version is { expected !r} " ,
92+ init ,
4593 )
4694 )
47- try :
48- installed = importlib .metadata .version (package .dist )
49- except importlib .metadata .PackageNotFoundError :
50- findings .append (Finding (f"{ package .dist } : not installed" , pyproject ))
51- continue
52- for root in package .roots :
53- runtime = getattr (importlib .import_module (root ), "__version__" , None )
54- if runtime is None :
55- findings .append (
56- Finding (f"{ root } defines no __version__" , package .src / root )
57- )
58- elif runtime != installed :
59- findings .append (
60- Finding (
61- f"{ root } .__version__ is { runtime !r} , distribution version is { installed !r} " ,
62- package .src / root / "__init__.py" ,
63- )
64- )
6595 return findings
6696
6797
98+ def _version_value (path : Path ) -> ast .expr | None :
99+ if not path .exists ():
100+ return None
101+ for node in ast .parse (path .read_text ()).body :
102+ match node :
103+ case ast .Assign (targets = targets , value = value ) if any (
104+ isinstance (t , ast .Name ) and t .id == "__version__" for t in targets
105+ ):
106+ return value
107+ # Generated clients annotate it: ``__version__: str = "..."``.
108+ case ast .AnnAssign (target = ast .Name (id = "__version__" ), value = value ):
109+ return value
110+ case _:
111+ pass
112+ return None
113+
114+
115+ def _literal_version (path : Path ) -> str | None :
116+ value = _version_value (path )
117+ if isinstance (value , ast .Constant ) and isinstance (value .value , str ):
118+ return value .value
119+ return None
120+
121+
68122if __name__ == "__main__" :
69123 main ("version consistency" , check )
0 commit comments