Skip to content

Commit 1acb778

Browse files
committed
Compare default values in the stub-drift test
The stub-drift test compared only method and parameter names, so a default changed on one side only (the Rust pyo3 signature or the type stub) would pass CI while type checkers report the wrong default. The prefer_meters_in_component_formulas flip is exactly this kind of change. Add a test that compares the __init__ default values from the stub AST against the runtime signature. Defaults that are not plain literals are marked and fail the comparison loudly; classes without an introspectable runtime signature are skipped, as in the name test. Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
1 parent f151e00 commit 1acb778

1 file changed

Lines changed: 68 additions & 0 deletions

File tree

tests/test_stub_drift.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,71 @@ def test_stub_and_runtime_init_params_agree() -> None:
8383
f" stub: {stub_params}\n"
8484
f" runtime: {runtime_params}"
8585
)
86+
87+
88+
_NON_LITERAL = "<non-literal stub default>"
89+
90+
91+
def _literal_or_marker(node: ast.expr) -> object:
92+
"""Evaluate a stub default, or return the ``_NON_LITERAL`` marker."""
93+
try:
94+
return ast.literal_eval(node)
95+
except ValueError:
96+
return _NON_LITERAL
97+
98+
99+
def _parse_stub_init_defaults() -> dict[str, dict[str, object]]:
100+
"""Parse the stub into ``{ClassName: {param_name: default_value}}``.
101+
102+
Only ``__init__`` parameters with a default are included. A default
103+
that is not a plain literal (like ``False`` or ``None``) is stored as
104+
the ``_NON_LITERAL`` marker; it never equals a runtime value, so a
105+
comparison against it fails loudly.
106+
"""
107+
tree = ast.parse(_STUB_PATH.read_text())
108+
out: dict[str, dict[str, object]] = {}
109+
for node in tree.body:
110+
if not isinstance(node, ast.ClassDef):
111+
continue
112+
for item in node.body:
113+
if not isinstance(item, ast.FunctionDef) or item.name != "__init__":
114+
continue
115+
args = item.args
116+
defaults: dict[str, object] = {}
117+
for arg, default in zip(args.args[-len(args.defaults) :], args.defaults):
118+
defaults[arg.arg] = _literal_or_marker(default)
119+
for kwarg, kw_default in zip(args.kwonlyargs, args.kw_defaults):
120+
if kw_default is not None:
121+
defaults[kwarg.arg] = _literal_or_marker(kw_default)
122+
out[node.name] = defaults
123+
return out
124+
125+
126+
def test_stub_and_runtime_init_defaults_agree() -> None:
127+
"""`__init__` default values in the stub must match the runtime signature.
128+
129+
The parameter-name test above can not catch a default changed on only
130+
one side -- for example a flag like `prefer_meters_in_component_formulas`
131+
flipped in the Rust `#[pyo3(signature = ...)]` but not in the stub.
132+
Type checkers and IDEs would then report the wrong default for a flag
133+
that inverts behavior.
134+
135+
Skips classes where the runtime signature is not introspectable, like
136+
the name test above.
137+
"""
138+
stub_defaults = _parse_stub_init_defaults()
139+
for cls in _PUBLIC_RUNTIME_CLASSES:
140+
try:
141+
runtime_sig = inspect.signature(cls)
142+
except ValueError:
143+
continue
144+
runtime_defaults = {
145+
name: param.default
146+
for name, param in runtime_sig.parameters.items()
147+
if param.default is not inspect.Parameter.empty
148+
}
149+
assert stub_defaults.get(cls.__name__, {}) == runtime_defaults, (
150+
f"{cls.__name__}.__init__ default drift:\n"
151+
f" stub: {stub_defaults.get(cls.__name__, {})}\n"
152+
f" runtime: {runtime_defaults}"
153+
)

0 commit comments

Comments
 (0)