Skip to content

Commit cbd34fc

Browse files
committed
Port verification rule updates from smithy-python
1 parent f73d639 commit cbd34fc

3 files changed

Lines changed: 115 additions & 42 deletions

File tree

‎scripts/verifications/dunder_all.py‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
never declared public. Unresolvable names are already caught by ruff F822.
1111
1212
Only modules that declare ``__all__`` are checked. A name counts as public when it
13-
is defined in the module and has no leading underscore.
13+
is defined in the module and has no leading underscore. In a package
14+
``__init__``, an import the module never uses exists only to re-export, so it must
15+
be listed in ``__all__``. A used import may be left out as a private re-export.
1416
"""
1517

1618
import ast
@@ -47,7 +49,7 @@ def check() -> list[Finding]:
4749
for name, imp in sorted(module.imported.items()):
4850
if name.startswith("_") or imp.top not in owned_roots() or imp.name is None:
4951
continue
50-
# An import the module itself uses is a dependency, not a re-export.
52+
# A used import is a private re-export, and a TYPE_CHECKING one is unbound.
5153
if name not in exported_set and (
5254
imp.context == "type_checking" or name in used
5355
):
@@ -58,10 +60,10 @@ def check() -> list[Finding]:
5860
if name not in exported_set:
5961
findings.append(
6062
Finding(
61-
f"re-exported {name} (from {target}) is missing from __all__",
63+
f"{name} (from {target}) is imported only to re-export but "
64+
"is missing from __all__",
6265
module.path,
6366
imp.line,
64-
"warning",
6567
)
6668
)
6769
continue
@@ -84,15 +86,34 @@ def check() -> list[Finding]:
8486

8587
def _used_names(tree: ast.Module) -> set[str]:
8688
used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
87-
for node in ast.walk(tree):
88-
if isinstance(node, ast.Constant) and isinstance(node.value, str):
89-
try:
90-
annotation = ast.parse(node.value, mode="eval")
91-
except SyntaxError:
92-
continue
93-
used.update(n.id for n in ast.walk(annotation) if isinstance(n, ast.Name))
89+
for annotation in _annotations(tree):
90+
for node in ast.walk(annotation):
91+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
92+
try:
93+
parsed = ast.parse(node.value, mode="eval")
94+
except SyntaxError:
95+
continue
96+
used.update(n.id for n in ast.walk(parsed) if isinstance(n, ast.Name))
9497
return used
9598

9699

100+
def _annotations(tree: ast.Module) -> list[ast.expr]:
101+
"""Annotation expressions, where a string literal is a forward reference."""
102+
found: list[ast.expr] = []
103+
for node in ast.walk(tree):
104+
match node:
105+
case ast.arg(annotation=ann) | ast.AnnAssign(annotation=ann) if ann:
106+
found.append(ann)
107+
case ast.FunctionDef(returns=ret) | ast.AsyncFunctionDef(returns=ret) if (
108+
ret
109+
):
110+
found.append(ret)
111+
case ast.TypeAlias(value=value):
112+
found.append(value)
113+
case _:
114+
pass
115+
return found
116+
117+
97118
if __name__ == "__main__":
98119
main("__all__ consistency", check)

‎scripts/verifications/export_names.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,8 @@ def check() -> list[Finding]:
4242
)
4343
)
4444
continue
45-
# Without __all__, an imported name is the module's dependency, not its export.
46-
imported = by_name[modname].imported
4745
names = getattr(module, "__all__", None) or [
48-
n for n in vars(module) if not n.startswith("_") and n not in imported
46+
n for n in vars(module) if not n.startswith("_")
4947
]
5048
stub_only = [
5149
n

‎scripts/verifications/version_consistency.py‎

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
gates read; the distribution version is what resolvers and PyPI read. If they
88
disagree, telemetry and support misattribute behavior to the wrong release. The
99
version 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

1821
def 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+
68122
if __name__ == "__main__":
69123
main("version consistency", check)

0 commit comments

Comments
 (0)