diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 2c17b391..a39a76d7 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -442,7 +442,7 @@ One row per place a call is written (or, for a synthesised edge, the construct t - **typescript** — end_line / end_column come from the expression row; the call-site row itself records only the start. - **javascript** — caller_id is the parser's enclosing method, or the module initializer for top-level code. end_line / end_column come from the expression row. `require()` is a module edge, not a call site. - **typescript** — PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor's name is on the callee's methods row. Filter them out with kind NOT IN (…) when counting calls. -- **python** — PROPERTY_READ, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. +- **python** — PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. - **python** — The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class's TYPE hash for METACLASS_CREATION, positioned at the class declaration. ### `call_edges` @@ -531,9 +531,11 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `DECORATOR_*` | python | Applying an unparenthesised decorator; the suffix is the parser's decorator kind: BARE, ATTRIBUTE, SUBSCRIPT, EXPRESSION (and CALL/ATTRIBUTE_CALL when the factory expression is not itself a call site). The site is the decorator hash. | | `METACLASS_CREATION` | python | A class statement invokes its metaclass's `__new__` / `__init__` at import time, whether the metaclass is written on the statement (`class X(metaclass=M)`) or inherited from a base, and the nearest base's `__init_subclass__`. No written call; the site is the class's type hash. | | `PROPERTY_READ` | python | Reading `obj.attr` where `attr` is a `@property` runs the getter; reading `Cls.attr` where the METACLASS defines `attr` as a property runs that getter. No written call; the site is the attribute-access expression. | +| `PROPERTY_WRITE` | python | Assigning `obj.attr = v` where `attr` is a `@property` with a setter runs the setter; `del obj.attr` runs its deleter. No written call; the site is the attribute-access expression. | | `CONTEXT_MANAGER` | python | `with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression. | | `ITERATION_PROTOCOL` | python | `for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression. | | `SUBSCRIPT_PROTOCOL` | python | `x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver's class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call. | +| `BUILTIN_PROTOCOL` | python | `repr(x)`, `str(x)`, `len(x)`, `hash(x)`, `bool(x)`, `iter(x)`, `next(x)`, `abs(x)`, `format(x)` and `reversed(x)` run the matching dunder of the argument's class (str falls back to `__repr__`, bool to `__len__`). The written call is to the builtin; this edge is the dunder it runs. The site is the argument expression. Its own kind so it is never counted as a written call. | **`call_edges.tier` values** diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 2e9af322..c70bf076 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -667,9 +667,11 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'DECORATOR_*', languages: P, meaning: 'Applying an unparenthesised decorator; the suffix is the parser\'s decorator kind: BARE, ATTRIBUTE, SUBSCRIPT, EXPRESSION (and CALL/ATTRIBUTE_CALL when the factory expression is not itself a call site). The site is the decorator hash.' }, { table: 'call_edges', column: 'kind', value: 'METACLASS_CREATION', languages: P, meaning: 'A class statement invokes its metaclass\'s `__new__` / `__init__` at import time, whether the metaclass is written on the statement (`class X(metaclass=M)`) or inherited from a base, and the nearest base\'s `__init_subclass__`. No written call; the site is the class\'s type hash.' }, { table: 'call_edges', column: 'kind', value: 'PROPERTY_READ', languages: P, meaning: 'Reading `obj.attr` where `attr` is a `@property` runs the getter; reading `Cls.attr` where the METACLASS defines `attr` as a property runs that getter. No written call; the site is the attribute-access expression.' }, + { table: 'call_edges', column: 'kind', value: 'PROPERTY_WRITE', languages: P, meaning: 'Assigning `obj.attr = v` where `attr` is a `@property` with a setter runs the setter; `del obj.attr` runs its deleter. No written call; the site is the attribute-access expression.' }, { table: 'call_edges', column: 'kind', value: 'CONTEXT_MANAGER', languages: P, meaning: '`with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression.' }, { table: 'call_edges', column: 'kind', value: 'ITERATION_PROTOCOL', languages: P, meaning: '`for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression.' }, { table: 'call_edges', column: 'kind', value: 'SUBSCRIPT_PROTOCOL', languages: P, meaning: '`x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver\'s class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call.' }, + { table: 'call_edges', column: 'kind', value: 'BUILTIN_PROTOCOL', languages: P, meaning: '`repr(x)`, `str(x)`, `len(x)`, `hash(x)`, `bool(x)`, `iter(x)`, `next(x)`, `abs(x)`, `format(x)` and `reversed(x)` run the matching dunder of the argument\'s class (str falls back to `__repr__`, bool to `__len__`). The written call is to the builtin; this edge is the dunder it runs. The site is the argument expression. Its own kind so it is never counted as a written call.' }, // entry_points.reason { table: 'entry_points', column: 'reason', value: 'main', languages: ['java', 'csharp'], meaning: 'A static `main`. C#: a static `Main`, or the method top-level statements compile to.' }, @@ -748,7 +750,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'typescript', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The TypeScript dispatch envelope is in dispatch_candidates, with basis `nominal` or `structural`.' }, { language: 'typescript', table: 'type_instantiated', note: 'Every row has how = `new`. Not restricted to client provenance: a type the library constructs is still a type that exists at run time, and dropping it would narrow the envelope unsoundly.' }, { language: 'typescript', table: 'call_sites', note: 'PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor\'s name is on the callee\'s methods row. Filter them out with kind NOT IN (…) when counting calls.' }, - { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, + { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, { language: 'python', table: 'call_sites', note: 'The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class\'s TYPE hash for METACLASS_CREATION, positioned at the class declaration.' }, { language: 'python', table: 'call_edges', note: 'A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row.' }, { language: 'java', table: 'call_edges', note: 'A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform\'s own subclasses, are the other possible targets. Stage the library to replace the label with the real method.' }, diff --git a/graph/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl index b1c334c5..c527a151 100644 --- a/graph/python/engine/call-edge-generation/call_chain.dl +++ b/graph/python/engine/call-edge-generation/call_chain.dl @@ -156,6 +156,40 @@ method_returns_method("client", m, r) :- expr_call_candidate(site, g), method_returns_method("client", g, r). +// A function returning a member it LOOKED UP BY NAME: `def get_visitor(self, node): return +// getattr(self, f"visit_{type(node).__name__}", None)`, called as `f = self.get_visitor(node); +// f(node)`. The lookup is resolved where it is written (name-resolution.dl's getattr clauses), +// and the method hands that set back exactly as it would hand back a bare name. +method_returns_method("client", m, r) :- + method_return_value_expr("client", m, e), + call_of_expr(e, site), call_name(site, "getattr"), + expr_denotes_method("client", e, r). + +// ── a call that hands back one of its arguments (py_returns_arg, resolution/builtins.dl) ── +// Matched through the import that binds the callee, never by the bare name: +// `functools.update_wrapper(...)` / `t.cast(...)` where the receiver is the name an `import` +// bound, or `update_wrapper(...)` bound by `from functools import update_wrapper`. A local +// function that happens to share the name does neither. +py_passthrough_arg(site, pos) :- + call_name(site, fn), call_receiver_object(site, obj), + expr_binding("client", rb, ctx, obj), ctx != "STORE", binding_lookup("client", rb, rb2), + import_binding("client", rb2, i), import_decl("client", k, mod, _, i), + (k = "MODULE_IMPORT" ; k = "MODULE_IMPORT_ALIAS"), // `import typing as t` is the alias kind + py_returns_arg(path, pos), cat(mod, cat(".", fn)) = path. +py_passthrough_arg(site, pos) :- + call_callee_is_value(site), call_callee_expr(site, callee), + expr_binding("client", rb, ctx, callee), ctx != "STORE", binding_lookup("client", rb, rb2), + import_binding("client", rb2, i), import_decl("client", _, path, _, i), + py_returns_arg(path, pos). +// the expression a value really is, through any number of such calls +py_passthrough_root(e, e) :- method_return_value_expr("client", _, e). +py_passthrough_root(e, x) :- + py_passthrough_root(e, c), call_of_expr(c, site), py_passthrough_arg(site, pos), + call_arg(site, pos, x). +method_returns_method("client", m, r) :- + method_return_value_expr("client", m, e), py_passthrough_root(e, x), x != e, + expr_names_method("client", x, r). + // ── decorator_hits_lib(SiteKey, LibMethodHash) ─────────────────────────────── // `@abstractmethod` names `abc.abstractmethod`, which HAS Python source and is in the // staged stdlib IR — it is a library boundary, not a blind spot. A BARE decorator has no @@ -320,6 +354,25 @@ iter_protocol_edge(src, caller, m) :- expr_type("client", src, t), iter_protocol_target(t, m), expr_ultimate_method("client", src, caller). +// ── property_write_edge(WriteExprHash, CallerMethodHash, AccessorMethodHash) ── +// The store and delete halves of the property protocol: `obj.x = v` calls x's setter, +// `del obj.x` its deleter (type_property_accessor, resolution/attribute-lookup.dl). Same +// shape as a read, keyed on the access's own name context. +property_write_edge(e, caller, m) :- + expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), + expr_name_context("client", "STORE", e), + expr_parent("client", e, "ATTRIBUTE_OBJECT", _, obj), + expr_type("client", obj, t), + type_property_accessor("client", t, n, "PROPERTY_SETTER", m), + expr_ultimate_method("client", e, caller). +property_write_edge(e, caller, m) :- + expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), + expr_name_context("client", "DEL", e), + expr_parent("client", e, "ATTRIBUTE_OBJECT", _, obj), + expr_type("client", obj, t), + type_property_accessor("client", t, n, "PROPERTY_DELETER", m), + expr_ultimate_method("client", e, caller). + // ── property_read_edge(ReadExprHash, CallerMethodHash, GetterMethodHash) ───── property_read_edge(e, caller, getter) :- expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), @@ -629,6 +682,33 @@ subscript_protocol_edge(sub, caller, m) :- mro_lookup("client", t, "__class_getitem__", m), expr_ultimate_method("client", sub, caller). +// ── BUILTIN PROTOCOL — `repr(x)` runs type(x).__repr__ ───────────────────────── +// A builtin function that exists to call one dunder of its argument: repr, str, len, +// hash, bool, iter, next, abs, format, reversed. The written call resolves to the builtin +// (C code, boundary_lib), and the dunder it runs -- a client method, often the one a test +// exists to check -- had no caller: `assert repr(v) == "<...>"` reached nothing of v's. +// Only the BARE builtin name (bound to nothing in the client, so it is the builtin), with +// exactly one argument (`str(b, "utf-8")` decodes and runs no __str__), on an argument the +// engine can type. str() falls back to __repr__ and bool() to __len__ when the class +// defines no __str__ / __bool__ of its own, as CPython's type slots do. The builtin names +// and their dunders are catalogued in resolution/builtins.dl (builtin_protocol_slot). +builtin_protocol_arg(e, fn, x) :- + call_of_expr(e, site), call_callee_is_value(site), call_callee_expr(site, callee), + expr_binding("client", b, _, callee), binding_lookup_unresolved("client", b, fn, _), + builtin_protocol_slot(fn, _), + call_arg(site, "0", x), !call_arg(site, "1", _), !call_kwarg(site, _, _). +// The edge's site is the ARGUMENT expression, as an iteration's is the iterated one: the +// call expression is already the written call to the builtin. +builtin_protocol_edge(x, caller, m) :- + builtin_protocol_arg(_, fn, x), builtin_protocol_slot(fn, d), + expr_type("client", x, t), mro_lookup("client", t, d, m), + expr_ultimate_method("client", x, caller). +builtin_protocol_edge(x, caller, m) :- + builtin_protocol_arg(_, fn, x), builtin_protocol_fallback(fn, d, d2), + expr_type("client", x, t), !type_has_member(t, d), mro_lookup("client", t, d2, m), + expr_ultimate_method("client", x, caller). +type_has_member(t, d) :- builtin_protocol_fallback(_, d, _), mro_lookup("client", t, d, _). + // ── THE THREE PROTOCOL EDGES ARE TIERED BY TARGET COUNT, like every other edge ── // // A property read, a context-manager entry and an iteration all reach their target @@ -668,9 +748,11 @@ subscript_protocol_edge(sub, caller, m) :- // from the graph rather than being re-tiered. A dropped edge is worse than a mislabelled // one, and the golden caught it. protocol_edge(e, d, caller, m) :- property_read_edge(e, caller, m), method_decl(_, d, _, _, _, m). +protocol_edge(e, d, caller, m) :- property_write_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- with_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- iter_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- subscript_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). +protocol_edge(e, d, caller, m) :- builtin_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). // Aggregate in its own stratum, mirroring site_client_target_count above. protocol_edge_target_count(e, d, n) :- @@ -731,6 +813,12 @@ call_chain_edge(e, caller, "-", getter, "client", cls, "PROPERTY_READ") :- property_read_edge(e, caller, getter), method_decl(_, d, _, _, _, getter), protocol_edge_class(e, d, cls). +// PROPERTY WRITE — `obj.x = v` runs x's setter and `del obj.x` its deleter. Likewise no +// call site, and its own kind so it is never counted as a written call. +call_chain_edge(e, caller, "-", m, "client", cls, "PROPERTY_WRITE") :- + property_write_edge(e, caller, m), method_decl(_, d, _, _, _, m), + protocol_edge_class(e, d, cls). + // CONTEXT MANAGER — the same shape: an edge with no call site, its own kind so it can // never be mistaken for a written call. call_chain_edge(cm, caller, "-", m, "client", cls, "CONTEXT_MANAGER") :- @@ -749,6 +837,12 @@ call_chain_edge(sub, caller, "-", m, "client", cls, "SUBSCRIPT_PROTOCOL") :- subscript_protocol_edge(sub, caller, m), method_decl(_, d, _, _, _, m), protocol_edge_class(sub, d, cls). +// BUILTIN PROTOCOL — likewise. Its own kind: the written call is to the builtin, and +// this edge is the dunder that builtin runs, never a second written call. +call_chain_edge(x, caller, "-", m, "client", cls, "BUILTIN_PROTOCOL") :- + builtin_protocol_edge(x, caller, m), method_decl(_, d, _, _, _, m), + protocol_edge_class(x, d, cls). + // A site whose caller could not be determined AT ALL would vanish from every rule // above. That must be impossible (the parser guarantees a non-empty owner), so it is // asserted rather than assumed: any such site is emitted with caller "-" so the diff --git a/graph/python/engine/config-resolution/knobs.dl b/graph/python/engine/config-resolution/knobs.dl index 19231f11..27c24871 100644 --- a/graph/python/engine/config-resolution/knobs.dl +++ b/graph/python/engine/config-resolution/knobs.dl @@ -165,6 +165,14 @@ py_parametrize_deco("parametrize"). py_parametrize_argnames_kw("argnames"). py_parametrize_indirect_kw("indirect"). py_parametrize_indirect_all("True"). +// The VALUES the runner hands in: the decorator's second argument (or `argvalues=`), a +// fixture's `params=`, a row wrapped as `pytest.param(v, ..., id=...)`, and, inside a fixture +// declared with params, the attribute `request.param` it reads the current one from. +py_parametrize_argvalues_kw("argvalues"). +py_fixture_params_kw("params"). +py_param_row_wrapper("param"). +py_fixture_request_param("request"). +py_request_param_attr("param"). // ── py_argnames_sep / py_argnames_lead: how "a, b" separates argument names ── // The runner splits the string on "," and strips each piece. Souffle cannot split, so a diff --git a/graph/python/engine/expression-resolution/expr-type.dl b/graph/python/engine/expression-resolution/expr-type.dl index d46b6eca..ed530619 100644 --- a/graph/python/engine/expression-resolution/expr-type.dl +++ b/graph/python/engine/expression-resolution/expr-type.dl @@ -585,6 +585,15 @@ binding_declared_nominal(p, bind, d) :- binding_declared_ref(p, bind, r), type_ref_resolved(p, d, r), !type_is_structural(p, d). +// `x: Optional[Order] = ...` / `x: Order | None = ...` IS an Order (or None) — the rule the +// parameter, return and field annotations already have (resolution/annotations.dl, +// "Optional[X] IS X"), which the local annotation alone was missing: a local written +// with the commonest modern spelling stayed untyped while the same annotation on a +// parameter resolved. +binding_declared_nominal(p, bind, t) :- + type_ref_owner(p, bind, "BINDING", r), + type_ref(p, k, "VARIABLE_ANNOTATION", _, _, r), annotation_optional_kind(k), + type_ref_element(p, r, t), !type_is_structural(p, t). // The FK is resolved on only 14 of 479 variable annotations, so the name-based lookup // beside it is what carries this -- the same two clauses the parameter path uses, with // the same kind restriction. A SUBSCRIPT or a UNION names no single type and is served by diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index 0ede5dbf..f7277835 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -326,7 +326,7 @@ py_argnames_sep_at(h, s, i) :- py_parametrize_argnames(h, s), py_argnames_sep(se // A whole token: led by the string's start, a separator or a space, and followed by its // end, a separator or a space, so `server` is not found inside `server_url`. .decl py_argnames_found(h:symbol, name:symbol, i:number) -py_argnames_found(h, p, i) :- py_fixture_value_param(h, p), py_parametrize_argnames(h, s), +py_argnames_found(h, p, i) :- py_argnames_demand(h, p), py_parametrize_argnames(h, s), strlen(s) > strlen(p), i = range(0, strlen(s) - strlen(p) + 1), substr(s, i, strlen(p)) = p. .decl py_argnames_led(h:symbol, name:symbol, i:number) py_argnames_led(h, p, 0) :- py_argnames_found(h, p, 0). @@ -346,10 +346,15 @@ py_value_row_kind("LIST"). py_argnames_pos(h, p, k) :- py_argnames_name_at(h, p, i), py_parametrize_argnames(h, s), k = count : { py_argnames_sep_at(h, s, j), j < i }. // ... or, for argnames written as a list or tuple of strings, its element position there. -py_argnames_pos(h, p, k) :- py_fixture_value_param(h, p), +py_argnames_pos(h, p, k) :- py_argnames_demand(h, p), decorator_expr("client", de, h), call_arg(de, "0", a), expr_parent("client", a, "ELEMENT", ks, el), expr_node("client", "LITERAL", _, p, el), k = to_number(ks). +// the argument names whose position is asked for: one a getfixturevalue call reads, and +// every argument the decorator supplies directly (its values type the parameter, below) +.decl py_argnames_demand(h:symbol, p:symbol) +py_argnames_demand(h, p) :- py_fixture_value_param(h, p). +py_argnames_demand(h, p) :- py_parametrize_direct(req, p), py_parametrize_on(req, h). .decl py_fixture_value_param(h:symbol, p:symbol) py_fixture_value_param(h, p) :- py_fixture_value_call(req, e), call_arg(e, "0", a), expr_node("client", "NAME_REFERENCE", _, p, a), @@ -362,6 +367,70 @@ py_fixture_request(req, name) :- py_fixture_value_call(req, e), expr_parent("client", tup, "ELEMENT", to_string(k), el), expr_node("client", "LITERAL", _, name, el), name != "". +// ── PARAMETRIZE AND FIXTURE PARAMS: the VALUES the runner hands an argument ── +// @pytest.mark.parametrize("field_cls", [String, Integer]) -> field_cls is one of the classes +// @pytest.mark.parametrize("obj, n", [(Box(), 1), pytest.param(Bag(), 2)]) +// @pytest.fixture(params=(Converter, BaseConverter)) +// def converter_cls(request): return request.param -> one of the classes +// The runner calls the test once per row with the row's values in place of the argument, +// so each value is an argument reaching the parameter exactly as one written at a call +// site is (resolution/value-flow.dl types it). Only values WRITTEN in the decorator, or in +// a display a single-write name in this module (or one it imports the name from) holds; +// a value list built by a call is not read. +.decl py_pz_rows(h:symbol, rows:symbol) +py_pz_rows(h, a) :- py_parametrize_on(_, h), decorator_expr("client", de, h), call_arg(de, "1", a). +py_pz_rows(h, a) :- py_parametrize_on(_, h), decorator_expr("client", de, h), call_kwarg(de, k, a), py_parametrize_argvalues_kw(k). +py_pz_rows(h, a) :- py_fixture_deco_on(_, h), decorator_expr("client", de, h), call_kwarg(de, k, a), py_fixture_params_kw(k). +// the display a rows expression is: written in place, or held by a single-write name +.decl py_pz_display(rows:symbol, d:symbol) +py_pz_display(a, a) :- py_pz_rows(_, a), py_value_row_kind(k), expr_node("client", k, _, _, a). +py_pz_display(a, d) :- py_pz_rows(_, a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + py_pz_name_display(b2, d). +.decl py_pz_name_display(b:symbol, d:symbol) +py_pz_name_display(b, d) :- binding_rebinding("client", "1", _, _, b), + expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, d), + py_value_row_kind(k), expr_node("client", k, _, _, d). +// `from tests.base import ALL_FIELDS`: the module-level binding of that name in the module +py_pz_name_display(b, d) :- import_binding("client", b, i), + import_alias("client", orig, _, i), orig != "", import_relative("client", _, pkg, i), pkg != "", + module_decl("client", _, pkg, _, mod), + binding_decl("client", orig, _, "MODULE_LEVEL", _, mb), binding_owner("client", mod, _, mb), + binding_rebinding("client", "1", _, _, mb), + expr_binding("client", mb, "STORE", tgt), assign_pair("client", tgt, d), + py_value_row_kind(k), expr_node("client", k, _, _, d). +// a row, with `pytest.param(v, ..., id=...)` read as its positional values +.decl py_pz_row(h:symbol, row:symbol) +py_pz_row(h, r) :- py_pz_rows(h, a), py_pz_display(a, d), expr_parent("client", d, "ELEMENT", _, r). +.decl py_pz_param_call(row:symbol) +py_pz_param_call(r) :- py_pz_row(_, r), call_of_expr(r, site), call_name(site, w), py_param_row_wrapper(w). +// the value at one argument's position in a row: the whole row (one argument name), else +// the row's k-th element +.decl py_pz_single(h:symbol) +py_pz_single(h) :- py_parametrize_argnames(h, s), !py_argnames_sep_at(h, s, _). +py_pz_single(h) :- py_fixture_deco_on(_, h). +.decl py_pz_value(h:symbol, k:number, v:symbol) +py_pz_value(h, 0, r) :- py_pz_single(h), py_pz_row(h, r), !py_pz_param_call(r). +py_pz_value(h, 0, v) :- py_pz_single(h), py_pz_row(h, r), py_pz_param_call(r), call_arg(r, "0", v). +py_pz_value(h, k, v) :- !py_pz_single(h), py_pz_row(h, r), !py_pz_param_call(r), + py_value_row_kind(rk), expr_node("client", rk, _, _, r), + expr_parent("client", r, "ELEMENT", ks, v), k = to_number(ks). +py_pz_value(h, k, v) :- !py_pz_single(h), py_pz_row(h, r), py_pz_param_call(r), + call_arg(r, ks, v), k = to_number(ks). +// the parameter each value reaches: a test's directly parametrized argument ... +.decl py_pz_param_value(ph:symbol, v:symbol) +py_pz_param_value(ph, v) :- py_parametrize_direct(req, name), py_parametrize_on(req, h), + !py_pz_single(h), py_argnames_pos(h, name, k), py_pz_value(h, k, v), + param_decl("client", name, _, _, req, ph). +py_pz_param_value(ph, v) :- py_parametrize_direct(req, name), py_parametrize_on(req, h), + py_parametrize_argname(req, h, name), py_pz_single(h), py_pz_value(h, 0, v), + param_decl("client", name, _, _, req, ph). +// ... and, in a fixture declared with params=, what `request.param` evaluates to +.decl py_pz_request_param(e:symbol, v:symbol) +py_pz_request_param(e, v) :- py_fixture_deco_on(f, h), py_pz_value(h, 0, v), + py_fixture_request_param(rq), param_decl("client", rq, _, _, f, ph), + py_request_param_attr(pa), attr_access("client", e, pa, obj), expr_names_param("client", obj, ph). + // (0) a fixture a CLASS declares serves the tests of that class and of its subclasses, // and nothing else; for them it wins over the module's fixture of the name. Read as a // plain same-module fixture it was also handed to a sibling class's tests, and the @@ -412,6 +481,53 @@ py_fixture_requested(m) :- py_fixture_injection(_, m, _). framework_edge(from, to, "fixture_injection", name, "by_name") :- py_fixture_injection(from, to, name). +// ── 2a. THE SAME LOOKUP, FROM SYNTAX ALONE ────────────────────────────────────── +// resolution/value-flow.dl types a parameter by the fixture serving it, so it needs the +// runner's lookup INSIDE the resolution fixpoint. py_fixture_injection cannot be read +// there: a fixture a conftest star-imports is found through module_member_method, which +// is resolution, and the nearest-conftest MAX over it would then be a cyclic aggregate +// souffle refuses to stratify. This chain keeps the runner's order (class, own module, +// nearest conftest by its declaring file) and leaves out the two clauses that need +// resolution — a star-imported fixture and a pytest_plugins one. Those still reach their +// tests through the injection edge; they only go untyped. +.decl py_fixs_in_file(fix:symbol, file:symbol) +py_fixs_in_file(fix, p) :- py_fixture_decl(fix, _), method_file("client", p, fix). +.decl py_fixs_same_module(req:symbol, name:symbol, fix:symbol) +py_fixs_same_module(req, name, fix) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), + !py_fixture_class_shadowed(req, name), + method_file("client", p, req), py_fixs_in_file(fix, p). +.decl py_fixs_conftest_cand(req:symbol, name:symbol, fix:symbol, depth:number) +py_fixs_conftest_cand(req, name, fix, dl) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), + py_fixs_in_file(fix, cp), py_fixture_scope_file(c), + strlen(cp) >= strlen(c), substr(cp, strlen(cp) - strlen(c), strlen(c)) = c, + d = substr(cp, 0, strlen(cp) - strlen(c)), + method_file("client", p, req), + strlen(p) > strlen(d), substr(p, 0, strlen(d)) = d, dl = strlen(d). +.decl py_fixs_nearest(req:symbol, name:symbol, depth:number) +py_fixs_nearest(req, name, m) :- py_fixs_conftest_cand(req, name, _, _), + m = max dl : { py_fixs_conftest_cand(req, name, _, dl) }. +.decl py_fixs_injection(from:symbol, to:symbol, name:symbol) +py_fixs_injection(req, fix, name) :- py_fixture_in_class(req, name, fix). +py_fixs_injection(req, fix, name) :- py_fixs_same_module(req, name, fix). +py_fixs_injection(req, fix, name) :- py_fixs_conftest_cand(req, name, fix, dl), + !py_fixture_class_shadowed(req, name), !py_fixs_same_module(req, name, _), + py_fixs_nearest(req, name, dl). + +// ── 2b. FIXTURE VALUE (what the runner hands the parameter) ──────────────────── +// py_fixture_value_type(Fixture, Type) — the value a fixture hands over: its return or +// yield value's type, or its declared return. Consumed by resolution/value-flow.dl, where +// the runner's call `test(fixture_value)` is one more argument reaching a parameter. +.decl py_fixture_value_type(fix:symbol, t:symbol) +py_fixture_value_type(fix, t) :- py_fixture_decl(fix, _), + method_return_value_expr("client", fix, e), expr_type("client", e, t). +py_fixture_value_type(fix, t) :- py_fixture_decl(fix, _), + method_declared_return_type("client", fix, d), declared_dispatch("client", d, t). +py_fixture_value_type(fix, t) :- py_fixture_decl(fix, _), + expr_node("client", "YIELD", _, _, y), expr_ultimate_method("client", y, fix), + expr_parent("client", y, "YIELD_VALUE", _, v), expr_type("client", v, t). + // ───────────────────────────────────────────────────────────────────────────── // 3. URL DISPATCH (route table -> view) // ───────────────────────────────────────────────────────────────────────────── diff --git a/graph/python/engine/resolution/annotations.dl b/graph/python/engine/resolution/annotations.dl index 0d671aa9..2b9087a4 100644 --- a/graph/python/engine/resolution/annotations.dl +++ b/graph/python/engine/resolution/annotations.dl @@ -199,6 +199,25 @@ type_ref_element(p, parentRef, t) :- annotation_owner_module(p, owner, ok, mod), type_name_in_module(p, mod, tn, t). +// ── union_operand(Prov, UnionRefHash, OperandRefHash) — every operand of a union ── +// `|` is LEFT-associative, so `A | B | None` is `(A | B) | None`: A and B are not +// children of the annotation but grandchildren, under a nested UNION_PEP604, and the +// depth-1 clauses above saw only that nested union (which names no type) and None. Every +// rule reading "Optional[X] IS X" therefore lost the members of any union with three or +// more operands, on a parameter, a return, a field or a local alike. +union_operand(p, r, c) :- + type_ref(p, k, _, _, _, r), annotation_optional_kind(k), type_ref_nesting(p, r, _, _, c). +union_operand(p, r, c) :- + union_operand(p, r, u), type_ref(p, "UNION_PEP604", _, _, _, u), type_ref_nesting(p, u, _, _, c). +// the operands BELOW depth 1 (depth 1 is the clauses above), resolved, and by name +type_ref_element(p, r, t) :- + union_operand(p, r, c), type_ref_nesting(p, _, _, d, c), d != "1", type_ref_resolved(p, t, c). +type_ref_element(p, r, t) :- + union_operand(p, r, c), type_ref_nesting(p, _, _, d, c), d != "1", + type_ref(p, _, "GENERIC_ARGUMENT", tn, _, c), tn != "", + type_ref_owner(p, owner, ok, r), annotation_owner_module(p, owner, ok, mod), + type_name_in_module(p, mod, tn, t). + // ── annotation_owner_module(Prov, OwnerHash, OwnerKind, ModuleHash) ────────── // The module an annotation was written in, whatever kind of declaration owns it. annotation_owner_module(p, ph, "METHOD_PARAM", mod) :- diff --git a/graph/python/engine/resolution/attribute-lookup.dl b/graph/python/engine/resolution/attribute-lookup.dl index 3c032d13..7b44fe2d 100644 --- a/graph/python/engine/resolution/attribute-lookup.dl +++ b/graph/python/engine/resolution/attribute-lookup.dl @@ -317,6 +317,15 @@ type_call_target(p, t, m) :- mro_lookup(p, t, "__call__", m). type_property_getter(p, t, n, m) :- mro_lookup(p, t, n, m), method_kind(p, "PROPERTY_GETTER", _, m). +// ── type_property_accessor(Prov, TypeHash, Name, Kind, MethodHash) ─────────── +// `@x.setter` and `@x.deleter` are the other two halves: `obj.x = v` runs the setter and +// `del obj.x` the deleter, and like the getter neither has a call site. They share the +// getter's name and binding, so they are found on the class that WINS the name in the +// receiver's MRO, by their method kind, rather than through mro_lookup's one answer. +type_property_accessor(p, t, n, k, m) :- + mro_winner(p, t, n, c), method_owner(p, c, m), method_decl(p, n, _, _, _, m), + method_kind(p, k, _, m), (k = "PROPERTY_SETTER" ; k = "PROPERTY_DELETER"). + // ── A USER-WRITTEN DATA DESCRIPTOR IS THE SAME PROTOCOL (issue #326) ───────── // `@property` IS a data descriptor; the decorator is sugar over `__get__`/`__set__`. So // the clause above reads one spelling of the protocol and a class-level diff --git a/graph/python/engine/resolution/builtins.dl b/graph/python/engine/resolution/builtins.dl index ca1e14d1..f530f72d 100644 --- a/graph/python/engine/resolution/builtins.dl +++ b/graph/python/engine/resolution/builtins.dl @@ -182,6 +182,18 @@ py_copy_module("copy"). py_copy_function("copy"). py_copy_function("deepcopy"). +// ── py_returns_arg(DottedName, Position) — a library call that hands back an argument ── +// Each returns, unchanged, the argument at Position (documented behaviour, not inference): +// functools.update_wrapper(wrapper, wrapped) -> wrapper (copies __name__/__doc__ onto it) +// typing.cast(T, value) -> value (a no-op at run time) +// A decorator written `return update_wrapper(wrapper, f)`, or the typed spelling +// `return t.cast(F, update_wrapper(wrapper, f))`, is therefore the same shape as one +// returning `wrapper` under @functools.wraps. Without these every method it decorates was +// decorator_replaced_target, and no call through the attribute reached anything +// (call-edge-generation/call_chain.dl). Neither module is staged in a client-only run. +py_returns_arg("functools.update_wrapper", "0"). +py_returns_arg("typing.cast", "1"). + // ── py_builtin_dynamic(Name) — the escape hatches ──────────────────────────── // These do not merely lack Python source; they make the PROGRAM unanalysable at that // point. A call to getattr/eval/exec means the engine cannot know what runs, and the @@ -358,3 +370,30 @@ class_definition_keyword("metaclass"). // (expression-resolution/expr-type.dl, clause a'), where the convention is the only // signal there is: a lambda has no annotation and no `def` to classify. instance_receiver_name("self"). + +// ── builtin_protocol_slot(Builtin, Dunder) / builtin_protocol_fallback ─────── +// The one-dunder builtins: `repr(x)` runs type(x).__repr__, and so on. str() falls back to +// __repr__ and bool() to __len__ when the class has no slot of its own. Read by +// call-edge-generation/call_chain.dl's BUILTIN PROTOCOL edges. +builtin_protocol_slot("repr", "__repr__"). +builtin_protocol_slot("str", "__str__"). +builtin_protocol_slot("len", "__len__"). +builtin_protocol_slot("hash", "__hash__"). +builtin_protocol_slot("bool", "__bool__"). +builtin_protocol_slot("iter", "__iter__"). +builtin_protocol_slot("next", "__next__"). +builtin_protocol_slot("abs", "__abs__"). +builtin_protocol_slot("format", "__format__"). +builtin_protocol_slot("reversed", "__reversed__"). +builtin_protocol_fallback("str", "__str__", "__repr__"). +builtin_protocol_fallback("bool", "__bool__", "__len__"). + +// ── dict_view_name / dict_view_slot / dict_items_view — a mapping's views ──── +// `keys()` yields the key (generic position 0), `values()` the value (position 1), and +// `items()` a (key, value) pair. Read by resolution/iteration.dl. +dict_view_name("keys"). +dict_view_name("values"). +dict_view_name("items"). +dict_view_slot("keys", "0"). +dict_view_slot("values", "1"). +dict_items_view("items"). diff --git a/graph/python/engine/resolution/dispatch.dl b/graph/python/engine/resolution/dispatch.dl index f1f50d21..ed56e8cc 100644 --- a/graph/python/engine/resolution/dispatch.dl +++ b/graph/python/engine/resolution/dispatch.dl @@ -142,7 +142,31 @@ call_candidate_count(site, n) :- // ── call_dispatch_over_cap(SiteHash) ───────────────────────────────────────── call_dispatch_over_cap(site) :- - call_candidate_count(site, n), dispatch_capped_at(cap), n > cap. + call_candidate_count(site, n), dispatch_capped_at(cap), n > cap, + !call_computed_name_family(site). + +// ── call_computed_name_family(SiteHash) — a fan the PROGRAM wrote down ─────── +// The cap guards against a receiver type inferred too wide, where most of the fan is the +// engine's imprecision. A callee looked up by a name with a written prefix -- +// `getattr(self, f"visit_{...}")`, directly, through the local it was stored in, or +// through a method that returns it -- is the program's own dispatch table: every member +// named with the prefix is a target by construction, and a visitor with sixty visit_* +// methods is sixty targets, not sixty guesses. Refusing it dropped the visitor's whole +// body from every test that drives it. +method_returns_computed_name(m) :- + method_return_value_expr("client", m, e), getattr_computed_name(e, _, _). +computed_name_value(e) :- getattr_computed_name(e, _, _). +computed_name_value(e) :- + call_of_expr(e, s), expr_call_candidate(s, m), method_returns_computed_name(m). +call_computed_name_family(site) :- + call_callee_is_value(site), call_callee_expr(site, callee), computed_name_value(callee). +call_computed_name_family(site) :- + call_callee_is_value(site), call_callee_expr(site, callee), + expr_binding("client", b, ctx, callee), ctx != "STORE", + binding_lookup("client", b, b2), + expr_binding("client", b2, "STORE", tgt), + assign_pair("client", tgt, val), + computed_name_value(val). // ── expr_resolves_to_method(SiteHash, MethodHash) ──────────────────────────── // THE DISAMBIGUATED CALLEE SET the edge layer consumes. Same relation name as Java's, diff --git a/graph/python/engine/resolution/iteration.dl b/graph/python/engine/resolution/iteration.dl index 7a3e3279..76d50692 100644 --- a/graph/python/engine/resolution/iteration.dl +++ b/graph/python/engine/resolution/iteration.dl @@ -98,6 +98,18 @@ with_target_declared(p, tgt, ct) :- method_decl(p, _, _, _, ret, m), self_return_name(ret). +// The same, WRITTEN rather than declared: an __enter__ with no return annotation whose +// return value is `self` (`def __enter__(self): return self`). The protocol hands the +// target what __enter__ returned, and that is the context manager itself. +with_target_declared(p, tgt, ct) :- + with_pair(p, tgt, cm), + expr_root_context(p, crc, cm), context_manager_enter_of(crc, en), + expr_type(p, cm, ct), + mro_lookup(p, ct, en, m), + !method_declared_return_type(p, m, _), + method_return_value_expr(p, m, e), + expr_node(p, "SELF_REFERENCE", _, _, e). + // AN __enter__ RETURN IS A DECLARED TYPE, not an exact one, so it goes out through // declared_dispatch and is registered with call_recv_declared in dispatch.dl. A declared // type escaping that gate has produced a confident-wrong answer four times in this engine. @@ -281,6 +293,79 @@ binding_element_type(p, b, t) :- expr_binding(p, b, _, tgt), element_type_of(p, src, t). +// ── A MAPPING'S VIEWS: `.values()`, `.keys()`, `.items()` ───────────────────── +// self.dump_fields: dict[str, Field] = {} +// for name, field_obj in self.dump_fields.items(): field_obj.serialize(...) +// Every clause above types the container itself; a view is a CALL on it, and none of them +// looked through one, so the commonest way to walk a mapping typed nothing -- and an +// `items()` loop binds a TUPLE target, which no clause bound at all. A mapping annotation +// names its key at generic position 0 and its value at position 1, so the slots are +// read positionally rather than through type_ref_element (which merges both, the safe +// direction for iterating the mapping itself, and wrong for a view that yields one side). +// view_slot names which side each view yields; `items()` yields (key, value) pairs. +// dict_view_slot / dict_items_view are CPython names, catalogued in resolution/builtins.dl. + +// the annotation a mapping-valued expression carries: a field (through self or through +// any typed object), a parameter, or a callee's declared return +mapping_annotation_of(src, r) :- + expr_attr_of_self("client", src, n, owner), + type_attr_field("client", owner, n, f), + type_ref_owner("client", f, "FIELD", r), + type_ref("client", k, "FIELD_TYPE", _, _, r), annotation_container_kind(k). +mapping_annotation_of(src, r) :- + attr_access("client", src, n, obj), + expr_type("client", obj, ot), + type_attr_field("client", ot, n, f), + type_ref_owner("client", f, "FIELD", r), + type_ref("client", k, "FIELD_TYPE", _, _, r), annotation_container_kind(k). +mapping_annotation_of(src, r) :- + expr_names_param("client", src, ph), + type_ref_owner("client", ph, "METHOD_PARAM", r), + type_ref("client", k, "METHOD_PARAM", _, _, r), annotation_container_kind(k). +mapping_annotation_of(src, r) :- + call_of_expr(src, site), expr_call_candidate(site, m), + type_ref_owner("client", m, "METHOD", r), + type_ref("client", k, "METHOD_RETURN", _, _, r), annotation_container_kind(k). +// a single-write local holding one of those +mapping_annotation_of(src, r) :- + expr_binding("client", b, ctx, src), ctx != "STORE", + binding_lookup("client", b, b2), + binding_rebinding("client", "1", _, _, b2), + expr_binding("client", b2, "STORE", tgt), + assign_pair("client", tgt, val), + mapping_annotation_of(val, r). + +// the type at one generic position of an annotation (resolved, or by name in its module) +type_ref_slot(r, pos, t) :- + type_ref_nesting("client", r, pos, "1", child), + type_ref("client", _, "GENERIC_ARGUMENT", _, _, child), + type_ref_resolved("client", t, child). +type_ref_slot(r, pos, t) :- + type_ref_nesting("client", r, pos, "1", child), + type_ref("client", _, "GENERIC_ARGUMENT", tn, _, child), tn != "", + type_ref_owner("client", owner, ok, r), + annotation_owner_module("client", owner, ok, mod), + type_name_in_module("client", mod, tn, t). + +// `m.keys()` / `m.values()` / `m.items()` written with no argument +dict_view_call(src, recv, view) :- + call_of_expr(src, site), call_name(site, view), dict_view_name(view), + call_receiver_object(site, recv), !call_arg(site, _, _). +// the element a `keys()` / `values()` view yields +element_type_of("client", src, t) :- + dict_view_call(src, recv, view), dict_view_slot(view, pos), + mapping_annotation_of(recv, r), r != "", type_ref_slot(r, pos, t). +// an `items()` pair's two slots, and the names a TUPLE loop target binds to them +dict_item_slot_type(src, pos, t) :- + dict_items_view(view), dict_view_call(src, recv, view), dict_view_slot(_, pos), + mapping_annotation_of(recv, r), type_ref_slot(r, pos, t). +binding_element_type("client", b, t) :- + iter_pair("client", tgt, src), + expr_node("client", "TUPLE", _, _, tgt), + expr_parent("client", tgt, "ELEMENT", pos, el), + expr_binding("client", b, _, el), + dict_item_slot_type(src, pos, t). + // ── iteration_untyped(Prov, TargetExprHash, IterableExprHash) ──────────────── // A loop whose container the engine could not type. Emitted so the cost is countable // rather than absorbed into a general "receiver untyped". diff --git a/graph/python/engine/resolution/name-resolution.dl b/graph/python/engine/resolution/name-resolution.dl index 30888484..b9345eb1 100644 --- a/graph/python/engine/resolution/name-resolution.dl +++ b/graph/python/engine/resolution/name-resolution.dl @@ -1008,18 +1008,41 @@ computed_name_prefix(nameArg, pre) :- expr_operator("client", op, nameArg), py_str_concat_operator(op), expr_parent("client", nameArg, "OPERAND_LEFT", _, l), expr_node("client", "LITERAL", _, pre, l), pre != "". +// The same prefix written as an f-string, `getattr(self, f"visit_{type(node).__name__}")`: +// the parser hands over an f-string's constant lead (the text before its first `{`) as its +// literal value, and every string the f-string can produce starts with it. +computed_name_prefix(nameArg, pre) :- + expr_node("client", "FSTRING", _, pre, nameArg), pre != "". getattr_computed_name(e, obj, pre) :- call_of_expr(e, site), call_name(site, "getattr"), call_arg(site, "0", obj), call_arg(site, "1", nameArg), computed_name_prefix(nameArg, pre). -expr_denotes_method("client", e, m) :- +// The receiver's type, and where the receiver is `self`, every CONSTRUCTED subclass of it too: +// a visitor base names `visit_*` members it does not define itself, and self is whichever +// subclass was built -- the same self-dispatch bound (RTA) dispatch.dl puts on `self.m()`. +getattr_computed_type(e, pre, t) :- getattr_computed_name(e, obj, pre), - expr_type("client", obj, t), + expr_type("client", obj, t). +getattr_computed_type(e, pre, s) :- + getattr_computed_name(e, obj, pre), + expr_node("client", "SELF_REFERENCE", _, _, obj), + expr_type("client", obj, k), + type_subclass("client", k, s), + type_instantiated_reachable("client", s). +getattr_computed_member(e, m) :- + getattr_computed_type(e, pre, t), mro_lookup("client", t, n, m), strlen(n) > strlen(pre), substr(n, 0, strlen(pre)) = pre. +// a member a decorator replaced is the callable the decorator returned (a wrapper) +getattr_computed_member(e, m) :- + getattr_computed_type(e, pre, t), + mro_lookup_decorated("client", t, n, m), + strlen(n) > strlen(pre), + substr(n, 0, strlen(pre)) = pre. +expr_denotes_method("client", e, m) :- getattr_computed_member(e, m). // ── getattr(obj, name, default) IS AT LEAST the default ────────────────────── // h = getattr(record, "highlighter", self.highlighter) diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 14ef74b5..6fd224af 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -97,6 +97,52 @@ param_arg_type(ph, t) :- param_decl("client", kw, _, _, m, ph), expr_type("client", a, t). +// ── a parameter the TEST RUNNER fills ───────────────────────────────────────── +// pytest calls `test_commit(session=)`: the call is the runner's, +// so no call site spells it, but it is an argument reaching a parameter all the same, and +// the parameter is typed exactly as one passed at a call site would be. Which fixture +// serves the parameter is framework-behavior/dispatch.dl's py_fixs_injection (2a), the +// runner's own lookup (same class, same module, nearest conftest, a pytest_plugins +// module), so this is as deterministic as a name the LEGB walk resolves. +// MEASURED across ten public suites: 1,529 method calls on such parameters were +// ambiguous_unknown, and everything derived from them (`tx = session.begin(); tx.commit()`) +// with them; a suite whose fixtures carry annotations resolved its own. +param_arg_type(ph, t) :- + py_fixs_injection(req, fix, pn), + param_decl("client", pn, _, _, req, ph), + py_fixture_value_type(fix, t). + +// ── a parameter's DEFAULT VALUE reaches it ──────────────────────────────────── +// `def getter(val, field_obj=field_obj): return field_obj.deserialize(val)` -- the closure +// idiom that pins a loop variable -- is called with one argument, so the only value +// field_obj ever holds is its default, and no call site passes one. The default is an +// argument the def statement writes once, evaluated where the def runs: one more value +// reaching the parameter, alongside whatever the call sites pass. +param_arg_type(ph, t) :- param_default_expr("client", e, ph), expr_type("client", e, t). +param_arg_class(ph, t) :- param_default_expr("client", e, ph), expr_type_class_object("client", e, t). +param_arg_method(ph, m) :- param_default_expr("client", e, ph), expr_denotes_method("client", e, m). + +// ── a parameter the runner fills from a PARAMETRIZE row, or from FIXTURE PARAMS ── +// `@pytest.mark.parametrize("cls", [String, Integer])` calls the test once per value, so +// each value reaches `cls` as an argument would: an instance types it, a class object +// makes it that class, a function makes it callable as that function. Inside a fixture +// declared `params=[...]`, `request.param` is each of those values in turn. +// framework-behavior/dispatch.dl reads the rows (py_pz_param_value, py_pz_request_param). +param_arg_type(ph, t) :- py_pz_param_value(ph, v), expr_type("client", v, t). +param_arg_class(ph, t) :- py_pz_param_value(ph, v), expr_type_class_object("client", v, t). +param_arg_method(ph, m) :- py_pz_param_value(ph, v), expr_denotes_method("client", v, m). +expr_type("client", e, t) :- py_pz_request_param(e, v), expr_type("client", v, t). +expr_type_class_object("client", e, t) :- py_pz_request_param(e, v), expr_type_class_object("client", v, t). +expr_denotes_method("client", e, m) :- py_pz_request_param(e, v), expr_denotes_method("client", v, m). +// a fixture that hands back a CLASS (`return request.param` over classes) makes the +// requesting parameter that class, as a returned instance makes it an instance +py_fixture_value_class(fix, t) :- py_fixture_decl(fix, _), + method_return_value_expr("client", fix, e), expr_type_class_object("client", e, t). +param_arg_class(ph, t) :- + py_fixs_injection(req, fix, pn), + param_decl("client", pn, _, _, req, ph), + py_fixture_value_class(fix, t). + // ── param_arg_method(ParamHash, MethodHash) — a CALLABLE reaching a parameter ── // `Delegator(target)` puts the FUNCTION `target` into the parameter `fn`. Java would // need a functional interface for this; in Python it is an ordinary assignment. diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index d6b069c6..3ab8b4a1 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -52,6 +52,11 @@ .decl py_fixture_value_method(v:symbol) .decl py_parametrize_deco(v:symbol) .decl py_parametrize_argnames_kw(v:symbol) +.decl py_parametrize_argvalues_kw(v:symbol) +.decl py_fixture_params_kw(v:symbol) +.decl py_param_row_wrapper(v:symbol) +.decl py_fixture_request_param(v:symbol) +.decl py_request_param_attr(v:symbol) .decl py_parametrize_indirect_kw(v:symbol) .decl py_parametrize_indirect_all(v:symbol) .decl py_argnames_sep(v:symbol) @@ -238,6 +243,7 @@ .decl element_lib_type_of(c0:symbol,c1:symbol) .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 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) @@ -313,6 +319,9 @@ .decl py_path_join_function(c0:symbol) .decl py_path_join_operator(c0:symbol) .decl py_copy_function(c0:symbol) +.decl py_returns_arg(c0:symbol,c1:symbol) +.decl py_passthrough_arg(c0:symbol,c1:symbol) +.decl py_passthrough_root(c0:symbol,c1:symbol) .decl dict_lookup_method(c0:symbol) .decl builtin_target(c0:symbol,c1:symbol) .decl builtin_object_init_target(c0:symbol) @@ -526,10 +535,14 @@ .decl field_holds_method(c0:symbol,c1:symbol,c2:symbol) .decl type_attr_method_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl type_attr_class_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl dict_view_name(c0:symbol) +.decl dict_items_view(c0:symbol) .decl computed_name_prefix(c0:symbol,c1:symbol) .decl expr_operator(c0:symbol,c1:symbol,c2:symbol) .decl local_holds_untyped_return(c0:symbol,c1:symbol) .decl getattr_computed_name(c0:symbol,c1:symbol,c2:symbol) +.decl getattr_computed_type(c0:symbol,c1:symbol,c2:symbol) +.decl getattr_computed_member(c0:symbol,c1:symbol) .decl method_is_async(c0:symbol,c1:symbol) .decl await_operand(c0:symbol,c1:symbol,c2:symbol) .decl subscript_index_is_slice(c0:symbol,c1:symbol) @@ -594,6 +607,8 @@ .decl decorator_hits_lib(c0:symbol,c1:symbol) .decl decorator_hits_builtin(c0:symbol,c1:symbol) .decl property_read_edge(c0:symbol,c1:symbol,c2:symbol) +.decl property_write_edge(c0:symbol,c1:symbol,c2:symbol) +.decl type_property_accessor(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl with_protocol_edge(c0:symbol,c1:symbol,c2:symbol) .decl iteration_protocol_sync(c0:symbol) .decl iteration_protocol_of(c0:symbol,c1:symbol) @@ -662,3 +677,17 @@ .decl binding_declared_call_protocol(c0:symbol,c1:symbol,c2:symbol) .decl binding_assigned_lambda(c0:symbol,c1:symbol,c2:symbol) .decl instance_attr_method_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl dict_view_slot(c0:symbol,c1:symbol) +.decl mapping_annotation_of(c0:symbol,c1:symbol) +.decl type_ref_slot(c0:symbol,c1:symbol,c2:symbol) +.decl dict_view_call(c0:symbol,c1:symbol,c2:symbol) +.decl dict_item_slot_type(c0:symbol,c1:symbol,c2:symbol) +.decl method_returns_computed_name(c0:symbol) +.decl computed_name_value(c0:symbol) +.decl call_computed_name_family(c0:symbol) +.decl py_fixture_value_class(c0:symbol,c1:symbol) +.decl builtin_protocol_slot(c0:symbol,c1:symbol) +.decl builtin_protocol_fallback(c0:symbol,c1:symbol,c2:symbol) +.decl builtin_protocol_arg(c0:symbol,c1:symbol,c2:symbol) +.decl builtin_protocol_edge(c0:symbol,c1:symbol,c2:symbol) +.decl type_has_member(c0:symbol,c1:symbol) diff --git a/graph/test/python/torture/client/f43_def_rebind.py b/graph/test/python/torture/client/f43_def_rebind.py index 107989af..3a454e7e 100644 --- a/graph/test/python/torture/client/f43_def_rebind.py +++ b/graph/test/python/torture/client/f43_def_rebind.py @@ -89,10 +89,10 @@ def call_branched() -> str: def read_property() -> int: """The getter survives — the control that keeps this rule off property pairs. - EXPECT: miss — `h.value = 5` invokes the SETTER, and the engine emits a - PROPERTY_READ edge for a property read and nothing at all for a property WRITE. - That is a pre-existing gap this fixture happens to expose, not something #383 - changed; the getter edge on the next line is what this family is asserting. + `h.value = 5` invokes the SETTER, and the engine now emits a PROPERTY_WRITE + edge for it beside the PROPERTY_READ for the read; both halves of the pair + are live, which is exactly what this control exists to keep true. The getter + edge on the next line is what this family is asserting. """ h = Holder() h.value = 5 diff --git a/graph/test/python/torture/expected/coverage.txt b/graph/test/python/torture/expected/coverage.txt index 06b88e32..daf7e130 100644 --- a/graph/test/python/torture/expected/coverage.txt +++ b/graph/test/python/torture/expected/coverage.txt @@ -1,4 +1,4 @@ -=== per-family coverage (tier-4, 471 scored sites) === +=== per-family coverage (tier-4, 474 scored sites) === f01 inheritance & MRO links= 10 found= 10 (100.0%) missed= 0 wide= 0 WRONG= 0 f02 callables & closures links= 10 found= 9 (90.0%) missed= 1 wide= 0 WRONG= 0 f03 generics links= 14 found= 14 (100.0%) missed= 0 wide= 0 WRONG= 0 @@ -38,16 +38,16 @@ f40 data descriptor links= 14 found= 14 (100.0%) missed= 0 wide= 0 WRONG= 0 f41 class attribute absent links= 3 found= 3 (100.0%) missed= 0 wide= 0 WRONG= 0 f42 f42 links= 6 found= 6 (100.0%) missed= 0 wide= 0 WRONG= 0 - f43 f43 links= 7 found= 7 (100.0%) missed= 0 wide= 1 WRONG= 0 + f43 f43 links= 10 found= 10 (100.0%) missed= 0 wide= 1 WRONG= 0 nestmod.py nestmod.py links= 1 found= 1 (100.0%) missed= 0 wide= 0 WRONG= 0 pkgmod relative imports (subpackage) links= 3 found= 3 (100.0%) missed= 0 wide= 0 WRONG= 0 - TOTAL links=434 found=428 (98.6%) missed= 6 wide=37 WRONG= 0 - recall 428/434 = 98.6% of the links that actually ran + TOTAL links=437 found=431 (98.6%) missed= 6 wide=37 WRONG= 0 + recall 431/437 = 98.6% of the links that actually ran wide 37 a member of a SOUND SET that did not run on this pass WRONG 0 a single target asserted as certain that never ran - EXPECTED-MISS cases: {'FOUND': 20, 'MISSED': 24} (a CONCRETE here means a known blind spot closed) + EXPECTED-MISS cases: {'FOUND': 18, 'MISSED': 23} (a CONCRETE here means a known blind spot closed) --- non-concrete sites --- f02_callables.py:31 MISSED true=[('lib', 'callables.py', 22)] engine=[] diff --git a/graph/test/python/torture/expected/torture.edges b/graph/test/python/torture/expected/torture.edges index 4d85cc1a..557d3208 100644 --- a/graph/test/python/torture/expected/torture.edges +++ b/graph/test/python/torture/expected/torture.edges @@ -635,6 +635,7 @@ known_edge PROPERTY_READ f40_data_descriptor.via_data_descriptor -> f40_data_des known_edge PROPERTY_READ f40_data_descriptor.via_delete_descriptor -> f40_data_descriptor.Deletable.__get__ known_edge PROPERTY_READ f40_data_descriptor.via_property -> f40_data_descriptor.Prop.slot known_edge PROPERTY_READ f43_def_rebind.read_property -> f43_def_rebind.Holder.value +known_edge PROPERTY_WRITE f43_def_rebind.read_property -> f43_def_rebind.Holder.value known_edge SELF_CALL f02_callables.HoldsCallables.run -> f02_callables.LocalCallable.__call__ known_edge SELF_CALL f28_await.Registry.into_a_field -> f28_await.Registry.build known_edge SELF_CALL f28_await.Registry.via_self_receiver -> f28_await.Registry.build diff --git a/graph/test/python/torture/expected/torture.oracle b/graph/test/python/torture/expected/torture.oracle index 95fd8d90..4267008a 100644 --- a/graph/test/python/torture/expected/torture.oracle +++ b/graph/test/python/torture/expected/torture.oracle @@ -1 +1 @@ -oracle=634 engine=624 agree=587 missing=47 extra=37 +oracle=634 engine=625 agree=588 missing=46 extra=37 diff --git a/parser/src/parsers/python/extractors/python-expression-extractor.ts b/parser/src/parsers/python/extractors/python-expression-extractor.ts index d6a8763d..3db5d622 100644 --- a/parser/src/parsers/python/extractors/python-expression-extractor.ts +++ b/parser/src/parsers/python/extractors/python-expression-extractor.ts @@ -1580,7 +1580,11 @@ export class PythonExpressionExtractor { return; } - case PythonExpressionKind.FSTRING: + case PythonExpressionKind.FSTRING: { + builder.withLiteral(PythonLiteralType.FSTRING, this.fstringPrefixOf(node)); + return; + } + case PythonExpressionKind.FSTRING_INTERPOLATION: { builder.withLiteral(PythonLiteralType.FSTRING, ''); return; @@ -3077,6 +3081,41 @@ export class PythonExpressionExtractor { return EntityUtils.normalizeWhitespace(parts.join('')); } + /** + * An f-string's CONSTANT LEAD: the text written before its first `{...}`. + * `f"visit_{type(node).__name__}"` evaluates to some string starting with + * `visit_`, whatever the interpolation yields, so the lead is a fact about + * every value it can produce. That is what `getattr(self, f"visit_{...}")` + * dispatch needs: the candidates are exactly the members named with that + * prefix. Text after the first interpolation is not a prefix of anything and + * is left out. Empty when the f-string opens with an interpolation. + */ + private fstringPrefixOf(node: Parser.SyntaxNode): string { + const parts: string[] = []; + let done = false; + const collect = (current: Parser.SyntaxNode): void => { + for (let index = 0; index < current.namedChildCount && !done; index += 1) { + const part = current.namedChild(index); + if (!part) { + continue; + } + if (part.type === 'interpolation') { + done = true; + return; + } + if (part.type === 'string_content') { + parts.push(part.text); + continue; + } + if (part.type === 'string' || part.type === 'concatenated_string') { + collect(part); + } + } + }; + collect(node); + return EntityUtils.normalizeWhitespace(parts.join('')); + } + private binaryOperatorOf(node: Parser.SyntaxNode): string { const left = node.childForFieldName('left'); const right = node.childForFieldName('right'); diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 0d5c5485..71d448e5 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -43,6 +43,12 @@ export interface ProjectModuleFacts extends ResolutionInput { * re-export fails to resolve. */ isPackage?: boolean; + /** + * Whether this module is a `.pyi` stub. A stub shipped beside its `.py` carries the + * same qualified name, and an import must reach the `.py`: that is the module the + * interpreter loads. A stub with no `.py` beside it stays the import's target. + */ + isStub?: boolean; } export interface ProjectResolutionStats { @@ -189,6 +195,8 @@ export class PythonResolutionLinker { const moduleByQualifiedName = new Map(); for (const module of modules) { + const seen = moduleByQualifiedName.get(module.qualifiedName); + if (seen !== undefined && module.isStub && !seen.isStub) continue; moduleByQualifiedName.set(module.qualifiedName, module); } diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 0c07497a..0ec56d2b 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -320,6 +320,7 @@ export class PythonProjectAnalyzer { qualifiedName: facts.module.getQualifiedName(), moduleHash: facts.module.getHash(), isPackage: isPythonPackageInitFileName(path.basename(filePath)), + isStub: filePath.endsWith('.pyi'), scopes: facts.scopes, bindings: facts.bindings, types: facts.types, diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index f5283b76..a1284328 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -12,7 +12,7 @@ + COMPUTED_CALL IIFE_CALL DYNAMIC_IMPORT_CALL DYNAMIC_CODE_CALL TAGGED_TEMPLATE_CALL FUNCTION_CALL_APPLY FUNCTION_CALL_CALL FUNCTION_CALL_BIND python SIMPLE_CALL METHOD_CALL SELF_CALL SUPER_CALL CHAINED_CALL SUBSCRIPT_CALL CONTEXT_MANAGER - PROPERTY_READ METACLASS_CREATION DYNAMIC_CALL UNKNOWN_CALLEE_CALL DECORATOR_{APPLICATION,ATTRIBUTE,BARE,CALL} + PROPERTY_READ PROPERTY_WRITE METACLASS_CREATION DYNAMIC_CALL UNKNOWN_CALLEE_CALL DECORATOR_{APPLICATION,ATTRIBUTE,BARE,CALL} csharp + boundary_generated known_implicit_ctor known_builtin_operator ambiguous_dynamic fan_capped event_dispatch runtime_observed (only with a runtime trace) · new property_read property_write @@ -109,7 +109,7 @@ 'DECORATOR_APPLICATION': 'decorator', 'DECORATOR_ATTRIBUTE': 'decorator', 'DECORATOR_BARE': 'decorator', 'DECORATOR_CALL': 'decorator', # an accessor: written as a field, run as a method - 'property_read': 'property', 'property_write': 'property', 'PROPERTY_READ': 'property', + 'property_read': 'property', 'property_write': 'property', 'PROPERTY_READ': 'property', 'PROPERTY_WRITE': 'property', # the language runs it at a block boundary 'CONTEXT_MANAGER': 'with', # run-time code loading diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py index f26ed10c..f15e6c4a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py @@ -10,6 +10,10 @@ starts no process and is not linked; a path naming a file of another language is in no graph of this language and is not linked either (a spawn across languages is not built here). +A Python spawn can name a MODULE instead of a path: `[sys.executable, "-m", "pkg"]` runs pkg/__main__.py, and +`-m pkg.tool` runs pkg/tool.py, as __main__ either way. The name after `-m` is resolved like an import, from the +repository root or a source root, and only to a file this graph indexed (`-m pytest`, `-m coverage` name none). + Both backends read this module (dl/impact.dl through the `spawns_fact` input, graph_sql through its edge list), so they cannot drift apart on what counts as a spawn. """ @@ -119,6 +123,32 @@ def _resolve(lits, f, known): return out +MODULE = re.compile(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*') +TEST_DIR = {'test', 'tests', 'testing'} + + +def _resolve_modules(lits, known): + """the indexed files `python -m pkg.mod` runs: pkg/mod/__main__.py for a package, else pkg/mod.py. + + The name is a MODULE, found on the import path, so it is matched from the repository root or from a source root + (`src/`): a directory that is no package itself and no test directory. `-m coverage run -m typer` names two + modules; the one this graph indexed is the one linked, and one it did not index (coverage, pytest) names none.""" + out = set() + for i in range(len(lits) - 1): + if lits[i] != '-m' or not MODULE.fullmatch(lits[i + 1]): continue + rel = lits[i + 1].replace('.', '/') + for cand in (rel + '/__main__.py', rel + '.py'): + hits = [] + for p in known: + if p != cand and not p.endswith('/' + cand): continue + root = p[:len(p) - len(cand)].rstrip('/') + if root and (root + '/__init__.py' in known or TEST_DIR & set(root.split('/'))): continue + hits.append((len(root), p)) + if hits: + out.add(min(hits)[1]); break + return out + + def links(test_files, mod_of, lines, at): """-> sorted [(caller, script_module, file, line)]: in a test file, a spawn whose arguments name an indexed script. @@ -140,7 +170,9 @@ def links(test_files, mod_of, lines, at): and text[max(0, m.start() - 40):m.start()].rstrip()[-1:] != '.'] for end in sorted(set(sites)): arg = _balanced(text, end) - targets = _resolve(_literals(text, arg), f, known) + lits = _literals(text, arg) + targets = _resolve(lits, f, known) + if fam == 'py': targets |= _resolve_modules(lits, known) if not targets: continue line = text.count('\n', 0, end) + 1 c = at(f, line) or mod_of.get(f) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 7a8dc195..eee28309 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -806,13 +806,31 @@ class Impact: byfile = set(files) # a module path as each language spells it -> the file it names. Both spellings of a Python package are # kept (app/settings.py and app/settings/__init__.py), because the import statement cannot tell them apart. + # A module is also named from its package root, the first directory above it that is not itself a package: + # in a src layout `src/app/core.py` is imported as `app.core`, and the repository-relative spelling alone + # matches no import of it. mod = {} for f in files: stem = os.path.splitext(f)[0] - mod.setdefault(stem.replace('/', '.'), f) - if stem.endswith('/__init__'): - mod.setdefault(stem[:-len('/__init__')].replace('/', '.'), f) + names = [stem] + if f.endswith(('.py', '.pyi')): + parts = stem.split('/'); k = len(parts) - 1 + while k > 0 and '/'.join(parts[:k]) + '/__init__.py' in byfile: k -= 1 + if k > 0: names.append('/'.join(parts[k:])) + for n in names: + mod.setdefault(n.replace('/', '.'), f) + if n.endswith('/__init__'): + mod.setdefault(n[:-len('/__init__')].replace('/', '.'), f) out = set() + # pytest imports every conftest.py from the rootdir down to a test file's directory before the file itself, + # so whatever a conftest imports is imported for each test file beneath it + conftests = [f for f in files if f.rsplit('/', 1)[-1] == 'conftest.py'] + for f in files: + b = f.rsplit('/', 1)[-1] + if not (b.endswith('.py') and (b.startswith('test_') or b.endswith('_test.py'))): continue + for c in conftests: + cd = os.path.dirname(c) + if not cd or f.startswith(cd + '/'): out.add((f, c)) for f in files: ext = os.path.splitext(f)[1].lower() if not ext: # a python shebang script (#1376) @@ -1321,7 +1339,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 = '63' # 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 = '67' # 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 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 @@ -1530,6 +1548,39 @@ class Impact: W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', link_line.get(r['call_site_id'], r['start_line'] or 0)) for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")] + self.protected_name_stubs()) + # A CALL UNDER `if __name__ == "__main__":` DOES NOT RUN ON IMPORT. The guard is true only when the file is run + # as a script, so a module body's calls inside it are real edges (the script makes them) but no hop for the + # import walk (dl/impact.dl up_running): otherwise a module ending in a demo block reads as breaking at import for + # every file that imports it. + guarded = {} + def guard_ranges(f): + if f not in guarded: + rs = [] + try: + import ast as _ast + def is_main(t): + if isinstance(t, _ast.Compare) and len(t.ops) == 1 and isinstance(t.ops[0], _ast.Eq): + sides = [t.left, t.comparators[0]] + return any(isinstance(x, _ast.Name) and x.id == '__name__' for x in sides) and \ + any(isinstance(x, _ast.Constant) and x.value == '__main__' for x in sides) + # `cond and __name__ == "__main__"`: a conjunct false on import keeps the block from running; + # an `or` would not, so it is not read as a guard + return isinstance(t, _ast.BoolOp) and isinstance(t.op, _ast.And) and any(is_main(v) for v in t.values) + for n in _ast.parse('\n'.join(self.lines(f))).body: + if isinstance(n, _ast.If) and is_main(n.test): + rs.append((n.lineno, n.end_lineno)) + except (SyntaxError, ValueError): + pass + guarded[f] = rs + return guarded[f] + sites_of = collections.defaultdict(list) + for r in g.q("SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id " + "WHERE e.callee_method_id IS NOT NULL"): + sy = g.sym.get(r['caller_id']) or {} + if sy.get('kind') == 'module' and (r['file_path'] or '').endswith('.py'): + sites_of[(r['caller_id'], r['callee_method_id'])].append((g.site_file(r['file_path']), r['start_line'] or 0)) + W('guard_only', sorted((a, b) for (a, b), ss in sites_of.items() + if all(any(lo <= ln <= hi for lo, hi in guard_ranges(f)) for f, ln in ss))) # a hand-off on EVERY line its call site spans: a chained registration (`router\n .route('/')\n .post(auth(), ctrl.h)`) # records its edges on the statement's first line, while the handler is named on a later one W('handoff_at', sorted({(r['caller_id'], r['callee_method_id'], g.site_file(r['file_path']), l) for r in g.q( diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f8d70e36..e831a6a7 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -937,7 +937,9 @@ parent_up(q, a, b, t) :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, fw_ // shape — which is the whole argument for keeping cases beside a corpus. .decl up_running(q:symbol, m:symbol, d:number) up_running(q, m, 0) :- seed(q, m). -up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_gate(a, b, _), d < 40. +// a module body's call made only under `if __name__ == "__main__":` runs when the file is a script, never on import +.decl guard_only(a:symbol, b:symbol) .input guard_only +up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_gate(a, b, _), !guard_only(a, b), d < 40. // the same gate as `up` (above): a caller that leaves the instance code keeps the route only on an instance given f .decl up_running_g(q:symbol, m:symbol, t:symbol, f:symbol, d:number) up_running_g(q, a, t, f, d+1) :- up_running(q, f, d), state_gate(a, f, t), edge(a, f, "known_edge"), d < 40. @@ -1092,6 +1094,16 @@ direct(q, c, "uses", cat("framework-mediated, not a call: servlet_filter via ", // twice, which is the shape that made an override count as both contract and reached. .decl protocol_member(t:symbol, m:symbol) protocol_member(t, m) :- member(t, m, n, _), match("__.*__", n), n != "__init__". +// Showing a value (`__repr__`, `__str__`, `__format__`), pickling or copying it (`__getstate__`, `__setstate__`, +// `__reduce__`, ...) and deleting from it (`__delitem__`, `__delattr__`, `__del__`) happen only when some code asks, +// so a constructor deep inside other code says nothing about whether they run, and taking the hop from every one of +// them named every test above it. A TEST that builds the object is the one that asks: for these the hop is taken +// from test code only. +.decl protocol_on_demand(n:symbol) +protocol_on_demand("__repr__"). protocol_on_demand("__str__"). protocol_on_demand("__format__"). +protocol_on_demand("__getstate__"). protocol_on_demand("__setstate__"). protocol_on_demand("__getnewargs__"). +protocol_on_demand("__getnewargs_ex__"). protocol_on_demand("__reduce__"). protocol_on_demand("__reduce_ex__"). +protocol_on_demand("__delitem__"). protocol_on_demand("__delattr__"). protocol_on_demand("__del__"). .decl constructs(c:symbol, t:symbol) constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i. // The hop is taken only where the CHANGE ITSELF is a protocol member, not wherever one turns up in the closure. @@ -1100,7 +1112,9 @@ constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i. // that cost 115 false pairs for 46 true ones. Narrowed to the seed it keeps what it was written for — a // declaration with NO call site by design, whose callers are whoever built the object — and drops the rest. .decl protocol_hop(q:symbol, c:symbol, m:symbol) -protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), constructs(c, t), c != m. +protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), member(t, m, n, _), !protocol_on_demand(n), constructs(c, t), c != m. +protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), member(t, m, n, _), protocol_on_demand(n), constructs(c, t), c != m, + decl_file(c, f), is_test_file(f). up(q, c, 1) :- protocol_hop(q, c, _). parent_up(q, a, b, "protocol") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, protocol_hop(q, a, b). // WHAT RUNS AT IMPORT. `settings = Settings()` at the top level of a module is executed by the import statement, diff --git a/tests/cases/python/builtin-runs-the-dunder/app/__init__.py b/tests/cases/python/builtin-runs-the-dunder/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/builtin-runs-the-dunder/app/validators.py b/tests/cases/python/builtin-runs-the-dunder/app/validators.py new file mode 100644 index 00000000..346245b8 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/app/validators.py @@ -0,0 +1,40 @@ +def describe_type(t): + return t.__name__ + + +def count_items(): + return 3 + + +def plain_text(): + return "plain" + + +class InstanceOf: + def __init__(self, type): + self.type = type + + def __repr__(self): + return f"" + + +class Bag: + def __len__(self): + return count_items() + + +class Plain: + def __repr__(self): + return plain_text() + + +class Shown: + def __str__(self): + return "shown" + + def __repr__(self): + return plain_text() + + +def instance_of(type): + return InstanceOf(type) diff --git a/tests/cases/python/builtin-runs-the-dunder/case.json b/tests/cases/python/builtin-runs-the-dunder/case.json new file mode 100644 index 00000000..0f6dc7a4 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "repr(v) runs type(v).__repr__: a test asserting a validator's repr covers what that __repr__ calls", + "run": ["impact", "describe_type", "--tests"], + "want": ["test_repr.py"], + "avoid": ["test_len.py", "test_other.py"]}, + {"why": "len(x) runs __len__, and bool(x) on a class with no __bool__ runs __len__ too", + "run": ["impact", "count_items", "--tests"], + "want": ["test_len.py"], + "avoid": ["test_repr.py"]}, + {"why": "str(x) on a class with no __str__ runs its __repr__", + "run": ["impact", "Plain.__repr__", "--tests"], + "want": ["test_str.py"]}, + {"why": "CONTROL: str(x) on a class that defines __str__ does not run its __repr__, and constructing it runs neither", + "run": ["impact", "Shown.__repr__", "--tests"], + "want": ["tests: 0 of"], + "avoid": ["test_str.py", "test_other.py"]}]} diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_len.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_len.py new file mode 100644 index 00000000..d74f9b85 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_len.py @@ -0,0 +1,6 @@ +from app.validators import Bag + + +def test_len(): + assert len(Bag()) == 3 + assert bool(Bag()) diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_other.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_other.py new file mode 100644 index 00000000..115c6281 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_other.py @@ -0,0 +1,5 @@ +from app.validators import Shown + + +def test_other(): + assert Shown() is not None diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py new file mode 100644 index 00000000..705b30de --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py @@ -0,0 +1,6 @@ +from app.validators import instance_of + + +def test_repr(): + v = instance_of(int) + assert repr(v) == "" diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_str.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_str.py new file mode 100644 index 00000000..bd948d90 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_str.py @@ -0,0 +1,9 @@ +from app.validators import Plain, Shown + + +def test_str_falls_back(): + assert str(Plain()) == "plain" + + +def test_str_own(): + assert str(Shown()) == "shown" diff --git a/tests/cases/python/decorator-returning-update-wrapper/case.json b/tests/cases/python/decorator-returning-update-wrapper/case.json new file mode 100644 index 00000000..58328af4 --- /dev/null +++ b/tests/cases/python/decorator-returning-update-wrapper/case.json @@ -0,0 +1,13 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a decorator returning functools.update_wrapper(wrapper, f) hands back `wrapper`: a call to the decorated method resolves to the wrapper, and the caller reaches the method through it rather than by name", + "run": ["impact", "Scaffold.other"], + "want": ["2 hop(s) App.use"], + "avoid": ["[by name] App.use"]}, + {"why": "the typed spelling `return t.cast(F, update_wrapper(wrapper, f))`, typing imported as an alias, is the same shape", + "run": ["impact", "Scaffold.route"], + "want": ["2 hop(s) App.get"], + "avoid": ["[by name] App.get"]}, + {"why": "CONTROL: an undecorated method keeps its resolved caller", + "run": ["impact", "Scaffold.add"], + "want": ["[resolved] Scaffold.route"]}]} diff --git a/tests/cases/python/decorator-returning-update-wrapper/pkg/__init__.py b/tests/cases/python/decorator-returning-update-wrapper/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py b/tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py new file mode 100644 index 00000000..08c14f15 --- /dev/null +++ b/tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py @@ -0,0 +1,45 @@ +import typing as t +from functools import update_wrapper + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + + +def setupmethod(f: F) -> F: + f_name = f.__name__ + + def wrapper_func(self, *args, **kwargs): + self._check(f_name) + return f(self, *args, **kwargs) + + return t.cast(F, update_wrapper(wrapper_func, f)) + + +def plain(f): + def inner(self, *args): + return f(self, *args) + + return update_wrapper(inner, f) + + +class Scaffold: + def _check(self, n): + return n + + @setupmethod + def route(self, rule): + return self.add(rule) + + @plain + def other(self, x): + return x + + def add(self, rule): + return rule + + +class App(Scaffold): + def get(self, rule): + return self.route(rule) + + def use(self): + return self.other(1) diff --git a/tests/cases/python/dict-view-loop-types-the-value/app/__init__.py b/tests/cases/python/dict-view-loop-types-the-value/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/dict-view-loop-types-the-value/app/schema.py b/tests/cases/python/dict-view-loop-types-the-value/app/schema.py new file mode 100644 index 00000000..6098b261 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/app/schema.py @@ -0,0 +1,55 @@ +def serialize_field(): + return "s" + + +def deserialize_field(): + return "d" + + +def describe_field(): + return "x" + + +def key_upper(): + return "K" + + +class Field: + def serialize(self, value): + return serialize_field() + + def deserialize(self, value): + return deserialize_field() + + def describe(self): + return describe_field() + + +class Name: + def upper(self): + return key_upper() + + +class Schema: + def __init__(self): + self.dump_fields: dict[str, Field] = {} + self.load_fields: dict[str, Field] = {} + self.names: dict[Name, int] = {} + + def dump(self, obj): + out = {} + for attr_name, field_obj in self.dump_fields.items(): + out[attr_name] = field_obj.serialize(obj) + return out + + def load(self, data): + return [f.deserialize(data) for f in self.load_fields.values()] + + def keys_only(self): + return [k.upper() for k in self.names.keys()] + + def counts(self): + total = 0 + for name, count in self.names.items(): + total += count + return total diff --git a/tests/cases/python/dict-view-loop-types-the-value/case.json b/tests/cases/python/dict-view-loop-types-the-value/case.json new file mode 100644 index 00000000..16c58f1e --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "`for k, v in self.dump_fields.items()` with `dump_fields: dict[str, Field]`: v is a Field (the VALUE of each item), so v.serialize() resolves", + "run": ["impact", "serialize_field", "--tests"], + "want": ["test_dump.py"], + "avoid": ["test_load.py", "test_keys.py"]}, + {"why": "`for f in self.load_fields.values()`: f is a Field", + "run": ["impact", "deserialize_field", "--tests"], + "want": ["test_load.py"], + "avoid": ["test_dump.py"]}, + {"why": "`.keys()` yields the KEY type: k.upper() on a dict[Name, int] is Name.upper", + "run": ["impact", "key_upper", "--tests"], + "want": ["test_keys.py"]}, + {"why": "CONTROL: an item's first slot is the key, never the value: in `for name, count in names.items()` nothing calls Field", + "run": ["impact", "describe_field", "--tests"], + "want": ["tests: 0 of"], + "avoid": ["test_keys.py", "test_dump.py"]}]} diff --git a/tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py b/tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py new file mode 100644 index 00000000..5a02e011 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py @@ -0,0 +1,5 @@ +from app.schema import Schema + + +def test_dump(): + assert Schema().dump(1) == {} diff --git a/tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py b/tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py new file mode 100644 index 00000000..58c35f10 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py @@ -0,0 +1,9 @@ +from app.schema import Schema + + +def test_keys(): + assert Schema().keys_only() == [] + + +def test_counts(): + assert Schema().counts() == 0 diff --git a/tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py b/tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py new file mode 100644 index 00000000..a6a32648 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py @@ -0,0 +1,5 @@ +from app.schema import Schema + + +def test_load(): + assert Schema().load(1) == [] diff --git a/tests/cases/python/fixture-value-types-the-parameter/app/__init__.py b/tests/cases/python/fixture-value-types-the-parameter/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/fixture-value-types-the-parameter/app/core.py b/tests/cases/python/fixture-value-types-the-parameter/app/core.py new file mode 100644 index 00000000..687b3245 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/app/core.py @@ -0,0 +1,26 @@ +class Session: + def __init__(self, name): + self.name = name + + def begin(self): + return Transaction(self) + + def close(self): + return None + + +class Transaction: + def __init__(self, session): + self.session = session + + def commit(self): + return validate(self.session.name) + + +def validate(name): + return bool(name) + + +class Ledger: + def post(self, amount): + return amount diff --git a/tests/cases/python/fixture-value-types-the-parameter/case.json b/tests/cases/python/fixture-value-types-the-parameter/case.json new file mode 100644 index 00000000..828a6f45 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a test parameter the runner fills from a yield fixture is typed by what the fixture yields, so a method called on it resolves and the test reaches the change", + "run": ["impact", "Session.begin", "--tests"], + "want": ["test_tx.py"], + "avoid": ["test_ledger.py"]}, + {"why": "the type carries through a local derived from the parameter (`tx = session.begin(); tx.commit()`), two calls below the fixture", + "run": ["impact", "validate", "--tests"], + "want": ["test_tx.py"], + "avoid": ["test_ledger.py"]}, + {"why": "a return fixture types its parameter the same way", + "run": ["impact", "Ledger.post", "--tests"], + "want": ["test_ledger.py"], + "avoid": ["test_tx.py"]}, + {"why": "CONTROL: a method no test calls on a fixture value reaches no test file through it", + "run": ["impact", "Session.close", "--tests"], + "avoid": ["test_ledger.py"]}]} diff --git a/tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py b/tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py new file mode 100644 index 00000000..12d7146d --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from app.core import Ledger, Session + + +@pytest.fixture +def session(): + s = Session("db") + yield s + s.close() + + +@pytest.fixture +def ledger(): + return Ledger() diff --git a/tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py b/tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py new file mode 100644 index 00000000..54302900 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py @@ -0,0 +1,2 @@ +def test_post(ledger): + assert ledger.post(3) == 3 diff --git a/tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py b/tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py new file mode 100644 index 00000000..54cfaa2e --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py @@ -0,0 +1,3 @@ +def test_commit(session): + tx = session.begin() + assert tx.commit() diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/__init__.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py new file mode 100644 index 00000000..e12ac85d --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py @@ -0,0 +1,97 @@ +from .visitor import NodeVisitor, traced + + +def emit_name(): + return "name" + + +def emit_const(): + return "const" + + +def helper_not_visited(): + return "other" + + +class CodeGenerator(NodeVisitor): + def visit_Name(self, node): + return emit_name() + + @traced + def visit_Const(self, node): + return emit_const() + + def visit_Kind0(self, node): + return 0 + + def visit_Kind1(self, node): + return 1 + + def visit_Kind2(self, node): + return 2 + + def visit_Kind3(self, node): + return 3 + + def visit_Kind4(self, node): + return 4 + + def visit_Kind5(self, node): + return 5 + + def visit_Kind6(self, node): + return 6 + + def visit_Kind7(self, node): + return 7 + + def visit_Kind8(self, node): + return 8 + + def visit_Kind9(self, node): + return 9 + + def visit_Kind10(self, node): + return 10 + + def visit_Kind11(self, node): + return 11 + + def visit_Kind12(self, node): + return 12 + + def visit_Kind13(self, node): + return 13 + + def visit_Kind14(self, node): + return 14 + + def visit_Kind15(self, node): + return 15 + + def visit_Kind16(self, node): + return 16 + + def visit_Kind17(self, node): + return 17 + + def visit_Kind18(self, node): + return 18 + + def visit_Kind19(self, node): + return 19 + + def visit_Kind20(self, node): + return 20 + + def visit_Kind21(self, node): + return 21 + + def visit_Kind22(self, node): + return 22 + + def visit_Kind23(self, node): + return 23 + + def leave_Name(self, node): + return helper_not_visited() diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py new file mode 100644 index 00000000..9800e398 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py @@ -0,0 +1,6 @@ +class Name: + pass + + +class Const: + pass diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py new file mode 100644 index 00000000..4984ddfe --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py @@ -0,0 +1,14 @@ +def parse_if_body(): + return "if" + + +class Parser: + def __init__(self, keyword): + self.keyword = keyword + + def parse_statement(self): + f = getattr(self, f"parse_{self.keyword}") + return f() + + def parse_if(self): + return parse_if_body() diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py new file mode 100644 index 00000000..4b718a1a --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py @@ -0,0 +1,22 @@ +from functools import update_wrapper + + +class NodeVisitor: + def get_visitor(self, node): + return getattr(self, f"visit_{type(node).__name__}", None) + + def visit(self, node): + f = self.get_visitor(node) + if f is not None: + return f(node) + return self.generic_visit(node) + + def generic_visit(self, node): + return None + + +def traced(f): + def new_func(self, node): + return f(self, node) + + return update_wrapper(new_func, f) diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/case.json b/tests/cases/python/getattr-fstring-prefix-dispatch/case.json new file mode 100644 index 00000000..b9dadc6d --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/case.json @@ -0,0 +1,60 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a visitor base's get_visitor returns getattr(self, f\"visit_{...}\"): visit() reaches every visit_* member of a constructed subclass -- 26 of them, past the dispatch cap of 20, because a prefix family is the program's own dispatch table -- so a test driving visit() covers what visit_Name calls", + "run": [ + "impact", + "emit_name", + "--tests" + ], + "want": [ + "test_generate.py" + ], + "avoid": [ + "test_parse.py" + ] + }, + { + "why": "a visit_* member a decorator replaced is reached through the wrapper the decorator returned", + "run": [ + "impact", + "emit_const", + "--tests" + ], + "want": [ + "test_generate.py" + ] + }, + { + "why": "the f-string prefix held in a local and called (`f = getattr(self, f\"parse_{kw}\"); f()`)", + "run": [ + "impact", + "parse_if_body", + "--tests" + ], + "want": [ + "test_parse.py" + ], + "avoid": [ + "test_generate.py" + ] + }, + { + "why": "CONTROL: a member whose name does not start with the prefix (leave_Name) is not a visit_ candidate: only the test calling it directly", + "run": [ + "impact", + "helper_not_visited", + "--tests" + ], + "want": [ + "test_leave.py" + ], + "avoid": [ + "test_generate.py", + "test_parse.py" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py new file mode 100644 index 00000000..a9d88382 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py @@ -0,0 +1,6 @@ +from app.compiler import CodeGenerator +from app.nodes import Name + + +def test_generate_name(): + assert CodeGenerator().visit(Name()) == "name" diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py new file mode 100644 index 00000000..f7c14497 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py @@ -0,0 +1,6 @@ +from app.compiler import CodeGenerator +from app.nodes import Name + + +def test_leave(): + assert CodeGenerator().leave_Name(Name()) == "other" diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py new file mode 100644 index 00000000..7a4fa382 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py @@ -0,0 +1,5 @@ +from app.parser import Parser + + +def test_parse_if(): + assert Parser("if").parse_statement() == "if" diff --git a/tests/cases/python/main-guard-does-not-run-on-import/case.json b/tests/cases/python/main-guard-does-not-run-on-import/case.json new file mode 100644 index 00000000..4d1ef066 --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/case.json @@ -0,0 +1,46 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a call made only under `if __name__ == \"__main__\":` runs when the file is a script, not on import: breaking `show` breaks no file that merely imports the module", + "run": [ + "impact", + "show", + "--tests" + ], + "want": [ + "0 of 2 test method(s) reach the change" + ], + "avoid": [ + "at import" + ] + }, + { + "why": "a guard written as a conjunction (`cond and __name__ == \"__main__\"`) is still false on import", + "run": [ + "impact", + "banner", + "--tests" + ], + "want": [ + "0 of 2 test method(s) reach the change" + ], + "avoid": [ + "at import" + ] + }, + { + "why": "CONTROL: a call the module body makes OUTSIDE the guard does run on import, so an importer breaks with `render`", + "run": [ + "impact", + "render", + "--tests" + ], + "want": [ + "test_other.", + "1 at import" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/main-guard-does-not-run-on-import/pkg/__init__.py b/tests/cases/python/main-guard-does-not-run-on-import/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py new file mode 100644 index 00000000..96beb1b6 --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py @@ -0,0 +1,21 @@ +def render(x): + return str(x) + + +def show(): + return render(1) + + +TABLE = render("header") + + +if __name__ == "__main__": + print(show()) + + +def banner(): + return render("b") + + +if render and __name__ == "__main__": + banner() diff --git a/tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py new file mode 100644 index 00000000..1a974c9c --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py @@ -0,0 +1,5 @@ +from pkg import demo + + +def test_render(): + assert demo.render(2) == "2" diff --git a/tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py new file mode 100644 index 00000000..5e8f6973 --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py @@ -0,0 +1,5 @@ +import pkg.demo + + +def test_nothing(): + assert True diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/case.json b/tests/cases/python/on-demand-dunder-hop-from-tests/case.json new file mode 100644 index 00000000..8ca71978 --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/case.json @@ -0,0 +1,9 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "showing a value is on demand: a test that builds the object is taken for `__repr__`, a constructor deep in the source is not", + "run": ["impact", "Point.__repr__", "--tests"], + "want": ["test_point.py"], + "avoid": ["test_use.py"]}, + {"why": "CONTROL: a protocol a constructed object does not avoid (`__eq__`) keeps the hop from every constructor", + "run": ["impact", "Point.__eq__", "--tests"], + "want": ["test_use.py", "test_point.py"]}]} diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/__init__.py b/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py b/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py new file mode 100644 index 00000000..c37360f8 --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py @@ -0,0 +1,17 @@ +class Point: + def __init__(self, x, y): + self.x, self.y = x, y + + def __repr__(self): + return f"Point({self.x}, {self.y})" + + def __eq__(self, other): + return (self.x, self.y) == (other.x, other.y) + + +def make(): + return Point(1, 2) + + +def use(): + return make() diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py new file mode 100644 index 00000000..c0b95782 --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py @@ -0,0 +1,5 @@ +from pkg.model import Point + + +def test_repr(): + assert repr(Point(1, 2)) == "Point(1, 2)" diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py new file mode 100644 index 00000000..17baa6db --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py @@ -0,0 +1,5 @@ +from pkg.model import use + + +def test_use(): + assert use() is not None diff --git a/tests/cases/python/optional-and-union-annotations/case.json b/tests/cases/python/optional-and-union-annotations/case.json new file mode 100644 index 00000000..5747f72b --- /dev/null +++ b/tests/cases/python/optional-and-union-annotations/case.json @@ -0,0 +1,12 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a local annotated `Optional[Foo]` is a Foo, as the same annotation on a parameter already was", + "run": ["impact", "Foo.go"], + "want": ["[resolved] l_optional"]}, + {"why": "a three-operand PEP 604 union nests left-associatively; every operand counts, on a parameter and on a local", + "run": ["impact", "Bar.go"], + "want": ["p_pipe3", "l_pipe"], + "avoid": ["[by name] p_pipe3", "[by name] l_pipe"]}, + {"why": "CONTROL: a two-operand union and a plain annotation resolve as before", + "run": ["impact", "Foo.go"], + "want": ["[resolved] p_pipe", "[resolved] p_plain"]}]} diff --git a/tests/cases/python/optional-and-union-annotations/pkg/__init__.py b/tests/cases/python/optional-and-union-annotations/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/optional-and-union-annotations/pkg/m.py b/tests/cases/python/optional-and-union-annotations/pkg/m.py new file mode 100644 index 00000000..71acab0a --- /dev/null +++ b/tests/cases/python/optional-and-union-annotations/pkg/m.py @@ -0,0 +1,46 @@ +import typing as t +from typing import Optional, Union + + +class Foo: + def go(self): + return 1 + + +class Bar: + def go(self): + return 2 + + +def p_plain(x: Foo): + return x.go() + + +def p_optional(x: Optional[Foo]): + return x.go() + + +def p_t_optional(x: t.Optional[Foo]): + return x.go() + + +def p_pipe(x: Foo | None): + return x.go() + + +def p_union(x: Union[Foo, Bar]): + return x.go() + + +def l_optional(y): + z: Optional[Foo] = y + return z.go() + + +def l_pipe(y): + z: Foo | Bar | None = y + return z.go() + + +def p_pipe3(x: Foo | Bar | None): + return x.go() diff --git a/tests/cases/python/parameter-default-types-it/app/__init__.py b/tests/cases/python/parameter-default-types-it/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/parameter-default-types-it/app/schema.py b/tests/cases/python/parameter-default-types-it/app/schema.py new file mode 100644 index 00000000..9e9827a5 --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/app/schema.py @@ -0,0 +1,48 @@ +def deserialized(): + return "d" + + +def handled(): + return "h" + + +def other_handled(): + return "o" + + +class Field: + def deserialize(self, value): + return deserialized() + + +def default_handler(): + return handled() + + +def other_handler(): + return other_handled() + + +class Schema: + def __init__(self): + self.fields: list[Field] = [Field()] + + def getters(self): + out = [] + for field_obj in self.fields: + def getter(val, field_obj=field_obj): + return field_obj.deserialize(val) + out.append(getter) + return out + + +def load(data): + return [g(data) for g in Schema().getters()] + + +def notify(event, handler=default_handler): + return handler() + + +def emit(): + return notify("e") diff --git a/tests/cases/python/parameter-default-types-it/case.json b/tests/cases/python/parameter-default-types-it/case.json new file mode 100644 index 00000000..76acc212 --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/case.json @@ -0,0 +1,13 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a closure's `field_obj=field_obj` default pins a loop variable: field_obj is a Field, so field_obj.deserialize() resolves and a test of load() covers it", + "run": ["impact", "deserialized", "--tests"], + "want": ["test_load.py"], + "avoid": ["test_emit.py"]}, + {"why": "a callable default (`handler=default_handler`) is what handler() runs when the caller passes none", + "run": ["impact", "handled", "--tests"], + "want": ["test_emit.py"]}, + {"why": "CONTROL: an argument passed at a call site still reaches the parameter alongside the default", + "run": ["impact", "other_handled", "--tests"], + "want": ["test_other.py"], + "avoid": ["test_load.py"]}]} diff --git a/tests/cases/python/parameter-default-types-it/tests/test_emit.py b/tests/cases/python/parameter-default-types-it/tests/test_emit.py new file mode 100644 index 00000000..4eb608e8 --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/tests/test_emit.py @@ -0,0 +1,5 @@ +from app.schema import emit + + +def test_emit(): + assert emit() == "h" diff --git a/tests/cases/python/parameter-default-types-it/tests/test_load.py b/tests/cases/python/parameter-default-types-it/tests/test_load.py new file mode 100644 index 00000000..b4b85e5d --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/tests/test_load.py @@ -0,0 +1,5 @@ +from app.schema import load + + +def test_load(): + assert load(1) == ["d"] diff --git a/tests/cases/python/parameter-default-types-it/tests/test_other.py b/tests/cases/python/parameter-default-types-it/tests/test_other.py new file mode 100644 index 00000000..2084637a --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/tests/test_other.py @@ -0,0 +1,5 @@ +from app.schema import notify, other_handler + + +def test_other(): + assert notify("e", other_handler) == "o" diff --git a/tests/cases/python/parametrize-values-type-the-argument/app/__init__.py b/tests/cases/python/parametrize-values-type-the-argument/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/parametrize-values-type-the-argument/app/fields.py b/tests/cases/python/parametrize-values-type-the-argument/app/fields.py new file mode 100644 index 00000000..016d46d0 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/app/fields.py @@ -0,0 +1,46 @@ +def string_done(): + return "s" + + +def integer_done(): + return "i" + + +def box_done(): + return "b" + + +def plain_done(): + return "p" + + +def never_done(): + return "n" + + +class String: + def deserialize(self, value): + return string_done() + + +class Integer: + def deserialize(self, value): + return integer_done() + + +class Box: + def open(self): + return box_done() + + +class Converter: + def __init__(self, strict=False): + self.strict = strict + + def structure(self, value): + return plain_done() + + +class Unused: + def structure(self, value): + return never_done() diff --git a/tests/cases/python/parametrize-values-type-the-argument/case.json b/tests/cases/python/parametrize-values-type-the-argument/case.json new file mode 100644 index 00000000..cd7c606e --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/case.json @@ -0,0 +1,18 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "@parametrize(\"FieldClass\", ALL_FIELDS) where ALL_FIELDS is a list of classes another test module holds: FieldClass is each class, FieldClass() constructs it, and field.deserialize() resolves", + "run": ["impact", "string_done", "--tests"], + "want": ["test_fields.py"], + "avoid": ["test_boxes.py", "test_convert.py"]}, + {"why": "several argument names: `box` is the SECOND element of each row, and pytest.param(...) rows count too", + "run": ["impact", "box_done", "--tests"], + "want": ["test_boxes.py"], + "avoid": ["test_fields.py"]}, + {"why": "a fixture declared params=(Converter,) returns request.param, a CLASS; the fixture that calls it builds a Converter, and the test's converter.structure() resolves", + "run": ["impact", "plain_done", "--tests"], + "want": ["test_convert.py"], + "avoid": ["test_fields.py"]}, + {"why": "CONTROL: string values naming a class are strings, not the class: nothing reaches Unused.structure", + "run": ["impact", "never_done", "--tests"], + "want": ["tests: 0 of"], + "avoid": ["test_strings.py", "test_convert.py"]}]} diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/__init__.py b/tests/cases/python/parametrize-values-type-the-argument/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/base.py b/tests/cases/python/parametrize-values-type-the-argument/tests/base.py new file mode 100644 index 00000000..5827e88d --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/base.py @@ -0,0 +1,3 @@ +from app.fields import Integer, String + +ALL_FIELDS = [String, Integer] diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py b/tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py new file mode 100644 index 00000000..92e04e2e --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from app.fields import Converter + + +@pytest.fixture(params=(Converter,)) +def converter_cls(request): + return request.param + + +@pytest.fixture +def converter(converter_cls): + return converter_cls(strict=True) diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py new file mode 100644 index 00000000..2808f6e1 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py @@ -0,0 +1,8 @@ +import pytest + +from app.fields import Box + + +@pytest.mark.parametrize("label, box", [("a", Box()), pytest.param("b", Box(), id="b")]) +def test_open(label, box): + assert box.open() diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py new file mode 100644 index 00000000..90693707 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py @@ -0,0 +1,2 @@ +def test_structure(converter): + assert converter.structure(1) diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py new file mode 100644 index 00000000..3e5715cf --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py @@ -0,0 +1,9 @@ +import pytest + +from tests.base import ALL_FIELDS + + +@pytest.mark.parametrize("FieldClass", ALL_FIELDS) +def test_none(FieldClass): + field = FieldClass() + assert field.deserialize(None) diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py new file mode 100644 index 00000000..08d096da --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.mark.parametrize("name", ["Unused", "String"]) +def test_names(name): + assert name.upper() diff --git a/tests/cases/python/property-setter-is-a-call/case.json b/tests/cases/python/property-setter-is-a-call/case.json new file mode 100644 index 00000000..ef9c8fbb --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/case.json @@ -0,0 +1,16 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "assigning `obj.x = v` runs x's @x.setter: a test that assigns reaches a change to the setter, one that only reads does not", + "run": ["impact", "pkg/m.py:10", "--tests"], + "want": ["test_write.py"], + "avoid": ["test_read.py"]}, + {"why": "`del obj.x` runs x's @x.deleter", + "run": ["impact", "pkg/m.py:14", "--tests"], + "want": ["test_delete.py"], + "avoid": ["test_read.py"]}, + {"why": "a setter's own calls are reached through the write too", + "run": ["impact", "check", "--tests"], + "want": ["test_write.py"]}, + {"why": "CONTROL: the getter keeps its readers", + "run": ["impact", "pkg/m.py:6", "--tests"], + "want": ["test_read.py"]}]} diff --git a/tests/cases/python/property-setter-is-a-call/pkg/__init__.py b/tests/cases/python/property-setter-is-a-call/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/property-setter-is-a-call/pkg/m.py b/tests/cases/python/property-setter-is-a-call/pkg/m.py new file mode 100644 index 00000000..49ecf51e --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/pkg/m.py @@ -0,0 +1,31 @@ +class Box: + def __init__(self): + self._v = 0 + + @property + def value(self): + return self._v + + @value.setter + def value(self, v): + self._v = check(v) + + @value.deleter + def value(self): + self._v = None + + +def check(v): + return v + + +def fill(b: Box): + b.value = 3 + + +def clear(b: Box): + del b.value + + +def read(b: Box): + return b.value diff --git a/tests/cases/python/property-setter-is-a-call/tests/test_delete.py b/tests/cases/python/property-setter-is-a-call/tests/test_delete.py new file mode 100644 index 00000000..fd8cf303 --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/tests/test_delete.py @@ -0,0 +1,7 @@ +from pkg.m import Box + + +def test_delete(): + b = Box() + del b.value + assert b.value is None diff --git a/tests/cases/python/property-setter-is-a-call/tests/test_read.py b/tests/cases/python/property-setter-is-a-call/tests/test_read.py new file mode 100644 index 00000000..843cb79f --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/tests/test_read.py @@ -0,0 +1,5 @@ +from pkg.m import Box + + +def test_read_only(): + assert Box().value == 0 diff --git a/tests/cases/python/property-setter-is-a-call/tests/test_write.py b/tests/cases/python/property-setter-is-a-call/tests/test_write.py new file mode 100644 index 00000000..b88149b7 --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/tests/test_write.py @@ -0,0 +1,7 @@ +from pkg.m import Box + + +def test_assign(): + b = Box() + b.value = 4 + assert b.value == 4 diff --git a/tests/cases/python/src-layout-conftest-import-time/case.json b/tests/cases/python/src-layout-conftest-import-time/case.json new file mode 100644 index 00000000..2efeb4d3 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/case.json @@ -0,0 +1,11 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a src-layout package is imported by its package name: `from app import lookup` in a test is an import of src/app/__init__.py, so the test file reaches what that import runs", + "run": ["impact", "src/app/registry.py:4", "--tests"], + "want": ["test_lookup.py"]}, + {"why": "pytest imports tests/conftest.py before every test file under it, so a function that runs while the package is imported (normalise, through the module-level @register) breaks a test file that never imports the package itself", + "run": ["impact", "src/app/registry.py:4", "--tests"], + "want": ["test_plain.py"]}, + {"why": "CONTROL: describe runs at no import, so a test file that does not call it is not reached by the import walk", + "run": ["impact", "src/app/registry.py:19", "--tests"], + "avoid": ["test_plain.py"]}]} diff --git a/tests/cases/python/src-layout-conftest-import-time/pyproject.toml b/tests/cases/python/src-layout-conftest-import-time/pyproject.toml new file mode 100644 index 00000000..6d1bd6e7 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "app" +version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = "tests" diff --git a/tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py b/tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py new file mode 100644 index 00000000..3df798e6 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py @@ -0,0 +1 @@ +from .registry import register, lookup diff --git a/tests/cases/python/src-layout-conftest-import-time/src/app/registry.py b/tests/cases/python/src-layout-conftest-import-time/src/app/registry.py new file mode 100644 index 00000000..43ce0e0a --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/src/app/registry.py @@ -0,0 +1,25 @@ +_TABLE = {} + + +def normalise(name): + return name.strip().lower() + + +def register(name): + def deco(fn): + _TABLE[normalise(name)] = fn + return fn + return deco + + +def lookup(name): + return _TABLE[normalise(name)] + + +def describe(name): + return f"handler {normalise(name)}" + + +@register("Default") +def default_handler(): + return "default" diff --git a/tests/cases/python/src-layout-conftest-import-time/tests/conftest.py b/tests/cases/python/src-layout-conftest-import-time/tests/conftest.py new file mode 100644 index 00000000..aa144041 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +import app + + +@pytest.fixture +def table(): + return app diff --git a/tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py b/tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py new file mode 100644 index 00000000..539bd9a1 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py @@ -0,0 +1,5 @@ +from app import lookup + + +def test_lookup(): + assert lookup("default")() == "default" diff --git a/tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py b/tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py new file mode 100644 index 00000000..2733328f --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py @@ -0,0 +1,2 @@ +def test_arithmetic(): + assert 1 + 1 == 2 diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/case.json b/tests/cases/python/stub-beside-source-resolves-to-source/case.json new file mode 100644 index 00000000..97a74a7f --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/case.json @@ -0,0 +1,8 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a package shipping .pyi stubs beside its .py modules: a call that resolves to the stub's declaration reaches the .py implementation of the same qualified name, the one CPython runs", + "run": ["impact", "pkg/impl.py:4", "--tests"], + "want": ["test_chunk.py"]}, + {"why": "CONTROL: the stub itself is never an edge target", + "run": ["impact", "pkg/impl.pyi:1", "--tests"], + "avoid": ["[resolved] test_chunk"]}]} diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py new file mode 100644 index 00000000..66c63213 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py @@ -0,0 +1 @@ +from .impl import * # noqa diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi new file mode 100644 index 00000000..475cf4ea --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi @@ -0,0 +1 @@ +from .impl import * diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py new file mode 100644 index 00000000..79628415 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py @@ -0,0 +1,5 @@ +__all__ = ["chunk"] + + +def chunk(xs, n): + return [xs[i:i + n] for i in range(0, len(xs), n)] diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi new file mode 100644 index 00000000..3f70ad89 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi @@ -0,0 +1 @@ +def chunk(xs: list, n: int) -> list: ... diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml new file mode 100644 index 00000000..c311e64b --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "pkg" +version = "0" diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py new file mode 100644 index 00000000..c459b14b --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py @@ -0,0 +1,5 @@ +import pkg as p + + +def test_chunk(): + assert p.chunk([1, 2, 3], 2) == [[1, 2], [3]] diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/case.json b/tests/cases/python/test-runs-a-module-with-dash-m/case.json new file mode 100644 index 00000000..01e3c4ec --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/case.json @@ -0,0 +1,68 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "`python -m pkg` runs pkg/__main__.py: a test that spawns it covers what the package's main reaches, though nothing imports pkg.cli", + "run": [ + "impact", + "render", + "--tests" + ], + "want": [ + "test_package_main.py", + "spawns" + ], + "avoid": [ + "test_docs.py", + "test_tally.py" + ] + }, + { + "why": "`-m coverage run -m pkg.tool` names two modules: coverage is not in this graph, pkg.tool is pkg/tool.py, run as __main__ so its main guard is taken", + "run": [ + "impact", + "go", + "--tests" + ], + "want": [ + "test_entry_points.py", + "spawns" + ], + "avoid": [ + "test_docs.py", + "test_package_main.py" + ] + }, + { + "why": "CONTROL: a string that only mentions `-m pkg` starts no process and links nothing; a function the module does not reach keeps exactly the test that calls it", + "run": [ + "impact", + "tally", + "--tests" + ], + "want": [ + "test_tally.py" + ], + "avoid": [ + "test_package_main.py", + "test_docs.py", + "spawns" + ] + }, + { + "why": "test-impact for an edit to a module the package main reaches selects the spawning test and not the one that only names the module", + "run": [ + "test-impact", + "{repo}", + "pkg/cli.py" + ], + "want": [ + "tests/test_package_main.py" + ], + "avoid": [ + "tests/test_docs.py" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__init__.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py new file mode 100644 index 00000000..4e28416e --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py new file mode 100644 index 00000000..9bcd4f9d --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py @@ -0,0 +1,5 @@ +from .core import render + + +def main(): + print(render("cli")) diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py new file mode 100644 index 00000000..73d21203 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py @@ -0,0 +1,6 @@ +def render(name): + return f"<{name}>" + + +def tally(xs): + return len(xs) diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py new file mode 100644 index 00000000..ac2490b2 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py @@ -0,0 +1,6 @@ +def go(): + return "go" + + +if __name__ == "__main__": + print(go()) diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py new file mode 100644 index 00000000..ac86725e --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py @@ -0,0 +1,5 @@ +USAGE = "run it with: python -m pkg" + + +def test_usage_mentions_the_module(): + assert "-m pkg" in USAGE diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py new file mode 100644 index 00000000..dcd4991c --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py @@ -0,0 +1,8 @@ +import subprocess +import sys + +CMD = [sys.executable, "-m", "coverage", "run", "-m", "pkg.tool"] + + +def test_tool_runs(): + assert subprocess.run(CMD, capture_output=True).returncode == 0 diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py new file mode 100644 index 00000000..0654d644 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py @@ -0,0 +1,7 @@ +import subprocess +import sys + + +def test_module_runs(): + out = subprocess.run([sys.executable, "-m", "pkg", "--help"], capture_output=True, text=True).stdout + assert out diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py new file mode 100644 index 00000000..4f0bf0c8 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py @@ -0,0 +1,5 @@ +from pkg.core import tally + + +def test_tally(): + assert tally([1, 2]) == 2 diff --git a/tests/cases/python/value-callee-is-unknown/case.json b/tests/cases/python/value-callee-is-unknown/case.json index 7d63b1c5..1d36b13c 100644 --- a/tests/cases/python/value-callee-is-unknown/case.json +++ b/tests/cases/python/value-callee-is-unknown/case.json @@ -67,7 +67,7 @@ ] }, { - "why": "fire calls a local holding getattr(self, f'on_{event}'): the site is named, and the target is not called independent", + "why": "fire calls a local holding getattr(self, event): no part of the name is written, so the site is named and the target is not called independent (an f-string or `+` prefix resolves; see getattr-fstring-prefix-dispatch)", "run": [ "path", "Machine.fire", diff --git a/tests/cases/python/value-callee-is-unknown/src/handlers.py b/tests/cases/python/value-callee-is-unknown/src/handlers.py index 586521f5..703cac05 100644 --- a/tests/cases/python/value-callee-is-unknown/src/handlers.py +++ b/tests/cases/python/value-callee-is-unknown/src/handlers.py @@ -39,7 +39,7 @@ def via_local(name): class Machine: def fire(self, event): - hook = getattr(self, f"on_{event}", None) + hook = getattr(self, event, None) if hook is not None: hook() diff --git a/tests/cases/python/with-enter-returns-self/app/__init__.py b/tests/cases/python/with-enter-returns-self/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/with-enter-returns-self/app/session.py b/tests/cases/python/with-enter-returns-self/app/session.py new file mode 100644 index 00000000..8e01cd89 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/app/session.py @@ -0,0 +1,38 @@ +def send_bytes(): + return b"sent" + + +def open_raw(): + return "raw" + + +class Session: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def request(self, url): + return send_bytes() + + +class Pool: + def __enter__(self): + return open_raw() + + def __exit__(self, *exc): + return False + + def request(self, url): + return send_bytes() + + +def fetch(url): + with Session() as session: + return session.request(url) + + +def fetch_pooled(url): + with Pool() as conn: + return conn.request(url) diff --git a/tests/cases/python/with-enter-returns-self/case.json b/tests/cases/python/with-enter-returns-self/case.json new file mode 100644 index 00000000..e94fc855 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/case.json @@ -0,0 +1,30 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "`with Session() as session` where Session.__enter__ is unannotated and returns self: session IS the Session, so session.request() resolves", + "run": [ + "impact", + "send_bytes", + "--tests" + ], + "want": [ + "test_fetch.py" + ] + }, + { + "why": "CONTROL: an unannotated __enter__ that returns something else does not type its target as the context manager: conn.request() stays a by-name match, never a resolved call", + "run": [ + "impact", + "Pool.request" + ], + "want": [ + "[by name] fetch_pooled" + ], + "avoid": [ + "[resolved] fetch_pooled" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/with-enter-returns-self/tests/test_fetch.py b/tests/cases/python/with-enter-returns-self/tests/test_fetch.py new file mode 100644 index 00000000..4ac13099 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/tests/test_fetch.py @@ -0,0 +1,5 @@ +from app.session import fetch + + +def test_fetch(): + assert fetch("u") == b"sent" diff --git a/tests/cases/python/with-enter-returns-self/tests/test_pooled.py b/tests/cases/python/with-enter-returns-self/tests/test_pooled.py new file mode 100644 index 00000000..a43b88e1 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/tests/test_pooled.py @@ -0,0 +1,5 @@ +from app.session import fetch_pooled + + +def test_pooled(): + fetch_pooled("u")