diff --git a/graph/python/engine/resolution/generics.dl b/graph/python/engine/resolution/generics.dl index 20e9e93e..964f502a 100644 --- a/graph/python/engine/resolution/generics.dl +++ b/graph/python/engine/resolution/generics.dl @@ -279,6 +279,13 @@ param_class_object_bound("client", ph, t) :- type_ref_nesting("client", r, _, "1", child), type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "", typevar_bound("client", vn, t). +// …and the same under a union (`cls: type[CmdType] | None = None`): see param_class_object_ref. +param_class_object_bound("client", ph, t) :- + param_class_object_ref("client", ph, s), + type_ref_owner("client", ph, "METHOD_PARAM", r), s != r, + type_ref_nesting("client", s, _, _, child), + type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "", + typevar_bound("client", vn, t). expr_type_class_object("client", e, t) :- expr_names_param("client", e, ph), param_class_object_bound("client", ph, t). diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 6fd224af..51b73f51 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -450,6 +450,22 @@ expr_type_class_object(p, e, t) :- annotation_names_a_class("type"). annotation_names_a_class("Type"). +// ── param_class_object_ref(Prov, ParamHash, SubscriptRef) ───────────────────── +// The `type[...]` subscript a parameter's annotation IS, or one operand of its union is. +// `cls: type[Command] | None = None` (and `Optional[type[Command]]`) is the default-None +// spelling of the same class-object parameter: the body replaces None with a default class +// and calls `cls(...)`. Read only at the top level, that call stayed unresolved, and so did +// every constructor reached through it. +param_class_object_ref("client", ph, r) :- + type_ref_owner("client", ph, "METHOD_PARAM", r), + type_ref("client", _, "METHOD_PARAM", tn, _, r), + annotation_names_a_class(tn). +param_class_object_ref("client", ph, s) :- + type_ref_owner("client", ph, "METHOD_PARAM", r), + union_operand("client", r, s), + type_ref("client", _, _, tn, _, s), + annotation_names_a_class(tn). + expr_type_class_object("client", e, t) :- expr_names_param("client", e, ph), type_ref_owner("client", ph, "METHOD_PARAM", r), @@ -457,6 +473,12 @@ expr_type_class_object("client", e, t) :- annotation_names_a_class(tn), type_ref_nesting("client", r, _, "1", child), type_ref_resolved("client", t, child). +expr_type_class_object("client", e, t) :- + expr_names_param("client", e, ph), + param_class_object_ref("client", ph, s), + type_ref_owner("client", ph, "METHOD_PARAM", r), s != r, + type_ref_nesting("client", s, _, _, child), + type_ref_resolved("client", t, child). // A name written MORE THAN ONCE takes the union, for the same reason the instance side // does: a sound set beats a blank, and the tier follows from the target count. diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 08cc0035..db011f11 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -244,6 +244,7 @@ .decl binding_element_lib_type(c0:symbol,c1:symbol) .decl param_declared_type_by_parser(c0:symbol,c1:symbol,c2:symbol) .decl union_operand(c0:symbol,c1:symbol,c2:symbol) +.decl param_class_object_ref(c0:symbol,c1:symbol,c2:symbol) .decl type_ref_element(c0:symbol,c1:symbol,c2:symbol) .decl annotation_owner_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl annotation_container_kind(c0:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 6eb56111..0167e2c4 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -671,12 +671,17 @@ def _string_literals(q): # 3. a string that names a MEMBER OF A TYPE THE SAME DECORATION NAMES: `@SelectProvider(type = StockSql.class, # method = "byShelf")` points at StockSql.byShelf; it is a reference to that method, not a key for this one. # Only decided with the graph (`names_member(type, name)`); without it the string is kept. -_STRING = re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'') +# 4. a string that names a PARAMETER OF THE DECLARATION IT DECORATES: `@option("--params", "-p", "params")` on +# `def main(url, params)` binds the value a caller passes after `--params` to `params`. The flags are what a caller +# writes to reach the declaration; the parameter name is written by every function that builds a dict with a +# `params` key, and joined as a key it made each of them a caller of the command. +# 5. a string inside ANOTHER call in the decoration: `type=File("wb")` configures a value, it names nothing registered. +_STRING =re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'') _KEYWORD_BEFORE = re.compile(r'(\w+)\s*[=:]\s*[\[{(]?\s*(?:(?:"[^"]*"|\'[^\']*\')\s*,\s*)*$') _TYPE_ARG = re.compile(r'(? 1: continue + if params and key in params: continue out.add(key) return sorted(out) +def _call_depth(t, i): + """how many parentheses are open at offset i of a decoration's text, strings blanked: 1 is the decoration's own + argument list""" + return _STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count('(') - \ + _STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count(')') + + +def _params_of(signature): + """the parameter names a `name(a, b=1, *c)` signature declares""" + m = re.search(r'\((.*)\)', signature or '') + if not m: return set() + return {re.sub(r'[:=].*$', '', p).strip().lstrip('*') for p in m.group(1).split(',')} - {''} + + def member_names(q): """names_member for decoration_key_strings, read from the graph: does a type of this simple name declare a member of that name""" @@ -720,13 +741,14 @@ def decoration_keys(q, site_file=None): # production code; nothing is lost by declining to read a test's own decoration as a registration. tests = {r[0] for r in q("SELECT id FROM symbols WHERE is_test = 1")} if _has(q, 'symbols') else set() members = member_names(q) + sigs = dict(q("SELECT id, signature FROM symbols WHERE signature IS NOT NULL")) if _has(q, 'symbols') else {} out = [] for owner, name, text, f, l in q("""SELECT owner_id, name, text, file, line FROM decorations WHERE text IS NOT NULL AND text <> '' AND owner_id IS NOT NULL"""): if owner in tests: continue short = (name or '').split('.')[-1] - for key in decoration_key_strings(text, name, members): + for key in decoration_key_strings(text, name, members, _params_of(sigs.get(owner))): kind = 'route' if key.startswith('/') else 'key' why = (f'registered as a route "{key}" by @{short} — the router calls it, no call site does' if kind == 'route' else f'registered under "{key}" by @{short} — whoever writes that string reaches it, and no call site does') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 9f3a4100..c1fb637b 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1343,7 +1343,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '69' # 69: a library-callback site is a library receiver only where a hand-back from it is summarised (ax_edges.library_receiver_sites); 68: both 67s below, from two branches; 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '70' # 70: fn_returns / fn_returns_param / decorated_call (a by-name decorator hop lands on the wrapper the decorator returns), a decoration string naming a parameter of the declaration is no registration key; 69: a library-callback site is a library receiver only where a hand-back from it is summarised (ax_edges.library_receiver_sites); 68: both 67s below, from two branches; 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1745,6 +1745,18 @@ class Impact: a, b = bym.get(r[0]), bym.get(r[1]) if a and b and a != b: deconame.append((a, b)) W('decorated_name', sorted(set(deconame))) + # …and where the engine could NOT resolve the decoration, what the decorator the name denotes RETURNS, so the + # by-name decorator hop (dl/impact.dl `deco_wrapper`) lands on the wrapper the callers run, not on the + # decorator's own body: `fn_returns(m, w)` m returns the function w, `fn_returns_param(m)` m returns an argument + # it was handed (the registering `return f`), `decorated_call(s, d)` the decoration is written as a call + # (`@route("/x")`: the decorator applied is what `route` returned, not `route`). + bym = {sy['method_id']: i for i, sy in g.sym.items() if sy.get('method_id')} + W('fn_returns', sorted({(bym[a], bym[b]) for a, b in (g.q("SELECT c1, c2 FROM ext_method_returns_method") if g.has('ext_method_returns_method') else []) + if a in bym and b in bym and a != b})) + W('fn_returns_param', sorted({(bym[a],) for (a,) in (g.q("SELECT c1 FROM ext_method_returns_param") if g.has('ext_method_returns_param') else []) + if a in bym})) + W('decorated_call', sorted({(r[0], r[1].split('.')[-1]) for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []) + if r[1] and re.match(r'\s*@?\s*[\w.]+\s*\(', r[2] or '')})) # ── the two spellings of one route ────────────────────────────────────────────────────────────────────── # A test asks for `/orders/o-1/price`; the handler is registered as `/orders/{order_id}/price`. Neither # string contains the other, and no call site joins them — the router does, at run time, by matching the diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 6c5f1978..e0664841 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -1069,7 +1069,35 @@ fw_edge(a, b, "spawns") :- spawns_fact(a, b), a != b. .decl dec_decl(d:symbol, w:symbol) dec_decl(d, w) :- decorated(_, d), named(d, w), 1 = count : { named(d, _) }. dec_decl(d, w) :- decorated(s, d), named(d, w), decl_file(s, f), decl_file(w, f). -fw_edge(a, b, "decorator by name") :- decorated(a, d), dec_decl(d, b), a != b. +// WHO RUNS WHAT THE DECORATOR RETURNED, NOT WHO WAS DECORATED. That hop, taken from the decorated declaration to +// the decorator's body, made the decorated function a caller of the decorator: `@bp.route("/") def index` put `index`, +// and through it every test that requests "/", above everything `route` calls. Nothing runs `route` when `index` is +// called: the decoration ran it once, where the `def` is. What a caller of the decorated NAME runs is whatever the +// decorator RETURNED for it: a wrapper (`def wrapper(*a): … return f(*a)`, which may reach the change), or the +// function itself (`return f`, a registration), which adds nothing. Where the decoration is written as a call +// (`@route("/")`), the decorator applied is what `route` returned, and it is THAT one's return the name denotes. +// fn_returns(m, w) m returns the function w (Python ext_method_returns_method) +// fn_returns_param(m) m returns an argument it was handed +// decorated_call(s, d) s carries the decoration d written as a call +// Where what the decorator returns is unknown, the hop stays as it was: the walk keeps a route it cannot judge. +.decl fn_returns(m:symbol, w:symbol) .input fn_returns +.decl fn_returns_param(m:symbol) .input fn_returns_param +.decl decorated_call(s:symbol, d:symbol) .input decorated_call +.decl deco_applied(s:symbol, x:symbol) +deco_applied(s, b) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d). +deco_applied(s, x) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x). +.decl deco_wrapper(s:symbol, w:symbol) +deco_wrapper(s, w) :- deco_applied(s, x), fn_returns(x, w), w != s. +.decl deco_known(s:symbol, d:symbol) +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d), fn_returns(b, _). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d), fn_returns_param(b). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x), fn_returns(x, _). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x), fn_returns_param(x). +// The decorated declaration stands for its wrapper: whatever reaches the name (a call, a route, a registry key) runs it. +// The engine leaves a call to a name whose decorator it could not resolve unresolved, so the hop is taken from the +// declaration itself rather than from its callers. +fw_edge(s, w, "decorator by name") :- deco_wrapper(s, w). +fw_edge(a, b, "decorator by name") :- decorated(a, d), dec_decl(d, b), a != b, !deco_known(a, d). // A CLIENT ACROSS A PROCESS: the sender depends on the handler it reaches (#1293). `remote` above makes the client a // DIRECT row; walked only there, a test that drives the client never reached a change to the handler, so // test-impact stopped at the route while impact named the client one line up. Walked here as its own tier, the diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json b/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json new file mode 100644 index 00000000..8fc2466f --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json @@ -0,0 +1,26 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a decorator matched only by its name that REGISTERS the function and hands it back unchanged: calling the decorated function never runs the decorator, so a change to what the decorator calls does not reach the decorated function (it used to be listed as a caller of the decorator, and everything reaching it with it)", + "run": [ + "impact", + "remember" + ], + "avoid": [ + "on_order_created" + ] + }, + { + "why": "the control: a decorator matched only by its name that hands back a WRAPPER. The decorated name now denotes the wrapper, so a change the wrapper reaches still reaches the decorated declaration", + "run": [ + "impact", + "tick" + ], + "want": [ + "crunch_numbers" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py new file mode 100644 index 00000000..1e560ecd --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py @@ -0,0 +1,12 @@ +from loader import load + +tools = load() + + +@tools.registered("order-created") +def on_order_created(order): + return order + + +def dispatch(kind, payload): + return tools.REGISTRY[kind](payload) diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py new file mode 100644 index 00000000..f0d989ca --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py @@ -0,0 +1,2 @@ +def load(): + return __import__("tools") diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py new file mode 100644 index 00000000..09b903cd --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py @@ -0,0 +1,5 @@ +from handlers import dispatch + + +def test_dispatch(): + assert dispatch("order-created", 1) == 1 diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py new file mode 100644 index 00000000..63ad00a4 --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py @@ -0,0 +1,5 @@ +from work import run_crunch + + +def test_crunch(): + assert run_crunch() == 6 diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py new file mode 100644 index 00000000..093c1318 --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py @@ -0,0 +1,25 @@ +"""Two decorators the project declares: one hands back a wrapper, one registers and hands back the function.""" +REGISTRY = {} + + +def timed(fn): + def wrapper(*args): + tick() + return fn(*args) + return wrapper + + +def tick(): + return 1 + + +def registered(name): + def register(fn): + REGISTRY[name] = fn + remember(name) + return fn + return register + + +def remember(name): + return name diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py new file mode 100644 index 00000000..372c130d --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py @@ -0,0 +1,13 @@ +"""The decorator is reached through a value the graph cannot type, so the decoration is matched by its name.""" +from loader import load + +tools = load() + + +@tools.timed +def crunch_numbers(n): + return n * 2 + + +def run_crunch(): + return crunch_numbers(3) diff --git a/tests/cases/python/decorator-the-project-declares/case.json b/tests/cases/python/decorator-the-project-declares/case.json index 0fcb2134..ee7d61f0 100644 --- a/tests/cases/python/decorator-the-project-declares/case.json +++ b/tests/cases/python/decorator-the-project-declares/case.json @@ -3,7 +3,7 @@ "src": ".", "checks": [ { - "why": "a decorator the project declares is a dependency of everything it wraps: `@exporter(\"csv\") def export_csv` means export_csv IS what exporter returned, so a test that drives the handler must run when the decorator changes — and nothing but the @ line says so, since the decorator has no call site and the table is read by a key. The control is a module of its OWN: a sibling in the same file is reached whatever the decorator does, because the decoration runs while that file is being imported", + "why": "a decorator the project declares is a dependency of everything it wraps' MODULE: `@exporter(\"csv\") def export_csv` runs exporter(\"csv\") and the function it returned while app.py is being imported, so a test that imports app must run when the decorator changes. Calling export_csv later never runs exporter (it registered the function and handed it back unchanged), so the route is the import, not a hop from the decorated function. The control is a module of its OWN: a sibling in the same file is reached whatever the decorator does, because the decoration runs while that file is being imported", "run": [ "impact", "exporter", @@ -11,7 +11,7 @@ ], "want": [ "test_export.py", - "decorator by name" + "at import" ], "avoid": [ "test_plain.py" @@ -32,14 +32,14 @@ ] }, { - "why": "the hop is named for what it is. The decoration carries a simple NAME, so the declaration it denotes is matched by that name and not resolved, and the answer must not sell that as a resolved call", + "why": "the hop is named for what it is: the decoration runs at import, and the answer says so rather than selling it as a resolved call", "run": [ "impact", "exporter", "--tests" ], "want": [ - "decorator by name" + "at import" ], "avoid": [ "how sure each route is: 3 sound" diff --git a/tests/cases/python/option-parameter-name-is-no-key/case.json b/tests/cases/python/option-parameter-name-is-no-key/case.json new file mode 100644 index 00000000..dd288be8 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/case.json @@ -0,0 +1,16 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a decoration string that names a parameter of the declaration it decorates binds that parameter; it is not a key anything dispatches on. Every dict with a `level` key wrote it, and was joined to the command as a caller", + "run": ["impact", "describe", "--tests"], + "avoid": ["defaults", "test_config.py"] + }, + { + "why": "the control: the flag is what a caller writes to reach the command, and a test that writes it still reaches the change", + "run": ["impact", "describe", "--tests"], + "want": ["test_cli.py"] + } + ] +} diff --git a/tests/cases/python/option-parameter-name-is-no-key/cli.py b/tests/cases/python/option-parameter-name-is-no-key/cli.py new file mode 100644 index 00000000..0bd55772 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/cli.py @@ -0,0 +1,17 @@ +"""A command whose decorations declare its parameters: the flags are what a caller writes, the last name is the +parameter the value is bound to.""" + + +def option(*decls, **settings): + def attach(fn): + return fn + return attach + + +@option("--level", "-l", "level", type=int) +def main(level=0): + return describe(level) + + +def describe(level): + return level diff --git a/tests/cases/python/option-parameter-name-is-no-key/config.py b/tests/cases/python/option-parameter-name-is-no-key/config.py new file mode 100644 index 00000000..6cf1e6bb --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/config.py @@ -0,0 +1,2 @@ +def defaults(): + return {"level": 3} diff --git a/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py b/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py new file mode 100644 index 00000000..9becc312 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py @@ -0,0 +1,6 @@ +from cli import main + + +def test_level_flag(): + args = ["--level", "2"] + assert main(int(args[1])) == 2 diff --git a/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py b/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py new file mode 100644 index 00000000..b5c2cfb5 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py @@ -0,0 +1,5 @@ +from config import defaults + + +def test_defaults(): + assert defaults()["level"] == 3 diff --git a/tests/cases/python/optional-class-object-parameter/case.json b/tests/cases/python/optional-class-object-parameter/case.json new file mode 100644 index 00000000..b0f17354 --- /dev/null +++ b/tests/cases/python/optional-class-object-parameter/case.json @@ -0,0 +1,16 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a class-object parameter spelled with its default (`cls: type[CmdType] | None = None`, `Optional[Type[Command]]`) holds a class just as `type[Command]` does: `cls(name)` constructs it, so the constructor's callers include the code that calls through the parameter, in a closure or not", + "run": ["impact", "Command.__init__"], + "want": ["decorator", "option"] + }, + { + "why": "the control: the bare `type[Command]` spelling was already read", + "run": ["impact", "Command.__init__"], + "want": ["fixed"] + } + ] +} diff --git a/tests/cases/python/optional-class-object-parameter/commands.py b/tests/cases/python/optional-class-object-parameter/commands.py new file mode 100644 index 00000000..7ec1b9f1 --- /dev/null +++ b/tests/cases/python/optional-class-object-parameter/commands.py @@ -0,0 +1,31 @@ +"""A factory whose class parameter defaults to None: the body puts the default class in and constructs it.""" +import typing as t + + +class Command: + def __init__(self, name): + self.name = name + + +class Group(Command): + pass + + +CmdType = t.TypeVar("CmdType", bound=Command) + + +def command(name, cls: type[CmdType] | None = None): + if cls is None: + cls = t.cast("type[CmdType]", Command) + + def decorator(f): + return cls(name) + return decorator + + +def option(name, cls: t.Optional[t.Type[Command]] = None): + return (cls or Command)(name) + + +def fixed(name, cls: type[Command]): + return cls(name)