From d2af5367ec14d19b1c1d145f8c9f8761c6d7b100 Mon Sep 17 00:00:00 2001 From: aayush598 Date: Fri, 14 Aug 2026 17:39:47 +0530 Subject: [PATCH] fix(python): avoid false edges for pytest decorators --- CHANGELOG.md | 1 + graphify/extractors/engine.py | 204 ++++++++++++++++++- tests/test_python_decorators.py | 334 ++++++++++++++++++++++++++++++++ 3 files changed, 531 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e11c331f..56ae754db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.42 (unreleased) +- Fix: avoid false decorator edges for pytest decorators when a corpus contains same-named local functions (#2732, thanks @aayush598). - Fix: a JS/TS `for...of` / `for...in` loop binding is now shadowed, so passing it as a call argument no longer fabricates an `indirect_call` edge to an unrelated same-named callable (#2685, thanks @ousamabenyounes); completes the loop/closure/catch shadow family (#2568/#2569/#2517). - Fix: graph provenance (`built_at_commit`) is stamped from the analysed repository rather than the shell's working directory, so `graphify extract` run from elsewhere records the target's commit, not the caller's (#2534 family; #2699, thanks @C0KERNEL). - Fix: `affected` resolves a seed passed as a `./`-relative path (or an absolute path when run from the repo root) instead of silently returning nothing (#2707, thanks @phudayyy). Note: an absolute-path seed still requires the working directory to be the analysed repo root. diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index c3b13bb73..87b5182eb 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3,10 +3,15 @@ import hashlib import importlib +from dataclasses import dataclass from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text from graphify.ids import normalize_id from graphify.extractors.models import LanguageConfig -from graphify.extractors.resolution import _resolve_js_import_target +from graphify.extractors.resolution import ( + _python_import_from_module, + _python_imported_names, + _resolve_js_import_target, +) from graphify.security import sanitize_metadata from pathlib import Path @@ -71,6 +76,11 @@ def _semantic_reference_edge( # unique-function rewire can collapse them onto an unrelated local definition # (a corpus defining its own `def wraps(...)` gets a false decorator edge). # Same name-based tradeoff as `patch`/`Mock` in _PYTHON_ANNOTATION_NOISE. +# Third-party decorators hit the same failure mode (#2732) but cannot be folded +# into this bare-name set: `_python_decorator_name` reduces `@pytest.fixture` +# to the tail `fixture`, a generic name a corpus may use for its OWN decorator. +# Pytest decorators are suppressed instead by qualified path / binding in +# `_is_pytest_decorator_noise`. _PYTHON_DECORATOR_NOISE = frozenset({ "property", "staticmethod", "classmethod", "abstractmethod", "abstractproperty", "cached_property", "wraps", "lru_cache", "cache", @@ -79,6 +89,34 @@ def _semantic_reference_edge( "final", "no_type_check", "runtime_checkable", "dataclass", }) +# pytest decorators are matched by qualified path, never by bare tail symbol: +# `fixture` may be a legitimate corpus-owned decorator. `pytest.mark.` is an +# open-ended prefix because plugin markers are unbounded third-party names. +_PYTEST_DECORATOR_PATHS = frozenset({ + "pytest.fixture", + "pytest.hookimpl", + "pytest.hookspec", +}) +_PYTEST_DECORATOR_PREFIXES = frozenset({"pytest.mark."}) +# pytest's exports that are themselves decorators when rebound directly +# (`from pytest import fixture, mark`): the import-scope scan suppresses only +# those, not every name pytest happens to export (`raises`, `approx`, …). +_PYTEST_DECORATOR_EXPORTS = frozenset({"fixture", "hookimpl", "hookspec", "mark"}) + +@dataclass(frozen=True) +class _PythonBindingEvent: + """One module-level pytest binding, resolved in source order. + + kind is "module_alias" (a name bound to the pytest module itself: + `import pytest`, `import pytest as pt`), "imported_symbol" (a name rebound + from pytest's namespace: `from pytest import fixture, mark`), "rebound" + (a module-level `def`/`class`/assignment rebinds the name to a local), or + "unbound" (`del pytest` removes the name again). + """ + start_byte: int + kind: str + name: str + def _python_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: """Walk a Python type annotation; append (name, role) where role is 'type' or 'generic_arg'. @@ -2673,6 +2711,12 @@ def _extract_generic( if config.ts_module == "tree_sitter_c_sharp": csharp_interface_names = _csharp_pre_scan_interfaces(root, source) + # Python only (#2732): ordered module-level pytest bindings, so the + # decorator branch can tell pytest's ambient decorators from a corpus's own. + pytest_bindings: list[_PythonBindingEvent] = [] + if config.ts_module == "tree_sitter_python": + pytest_bindings = _python_pytest_bindings(root, source) + swift_protocol_names: set[str] = set() swift_class_names: set[str] = set() if config.ts_module == "tree_sitter_swift": @@ -4289,6 +4333,17 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # no false rewires onto same-named local definitions. if not deco_name or deco_name in _PYTHON_DECORATOR_NOISE: continue + # pytest decorators are ambient test vocabulary too, but + # matched by qualified path / import scope — NOT by bare + # tail symbol, which would also silence a corpus's own + # `@fixture` decorators (#2732). + if _is_pytest_decorator_noise( + _python_decorator_path(child, source), + pytest_bindings, + # The decorator's own offset, which precedes the def. + child.start_byte, + ): + continue deco_line = child.start_point[0] + 1 target = ensure_named_node(deco_name, deco_line) if target != owner_nid: @@ -5358,13 +5413,135 @@ def _scan_js_module_dispatch(n) -> None: result["cpp_type_table"] = {"path": str_path, "table": type_table} return result -def _python_decorator_name(deco_node, source: bytes) -> str | None: - """Return the head symbol of a Python `decorator` node. +def _python_pytest_bindings(root, source: bytes) -> list[_PythonBindingEvent]: + """Module-level pytest binding events, in source order (see `_PythonBindingEvent`). + + `_is_pytest_decorator_noise` replays the events up to a decorator's byte + offset, so the binding actually in force at that site decides: a later + rebinding must not invalidate an earlier `@fixture` that genuinely + referenced pytest, and a name rebound before a decorator must not be + treated as pytest vocabulary (#2732). Function and class bodies are not + descended into — their bindings are local, mirroring + `_python_module_bound_names`. This is a conservative tracker for pytest + decorator classification only, not a complete Python binding model: a + wildcard `from pytest import *` records no events, so it is conservatively + not treated as pytest vocabulary. + """ + # Unlike `_python_module_bound_names`, which folds bindings into a final + # set, this records ordered events so the binding in force AT A LOCATION + # (before/after a rebinding) can be replayed per decorator site. + events: list[_PythonBindingEvent] = [] + + def walk(n) -> None: + for child in n.children: + t = child.type + if t in ("function_definition", "class_definition"): + # Definitions bind their names in module scope, but their + # bodies are nested scopes and must not be traversed. + name = child.child_by_field_name("name") + if name is not None: + events.append(_PythonBindingEvent( + child.start_byte, "rebound", _read_text(name, source))) + continue + if t == "lambda": + continue # expression — binds nothing at module scope + if t == "import_statement": + for c in child.children: + if c.type == "dotted_name": + if _read_text(c, source) == "pytest": + events.append(_PythonBindingEvent( + c.start_byte, "module_alias", "pytest")) + elif c.type == "aliased_import": + name_node = c.child_by_field_name("name") + alias_node = c.child_by_field_name("alias") + if name_node is not None and _read_text(name_node, source) == "pytest": + alias = _read_text(alias_node, source) if alias_node is not None else "pytest" + events.append(_PythonBindingEvent(c.start_byte, "module_alias", alias)) + elif t == "import_from_statement": + module = _python_import_from_module(child, source) + if module is not None: + level, module_name = module + if level == 0 and module_name == "pytest": + for name, local_name in _python_imported_names(child, source): + if name in _PYTEST_DECORATOR_EXPORTS: + events.append(_PythonBindingEvent( + child.start_byte, "imported_symbol", local_name)) + elif t in ("assignment", "for_statement", "for_in_clause", "named_expression"): + # `x = ...`, `for x in ...`, walrus `x := ...`: local rebinding. + field = "left" if t != "named_expression" else "name" + targets: set[str] = set() + _python_collect_assignment_targets( + child.child_by_field_name(field), source, targets + ) + for target in targets: + events.append(_PythonBindingEvent(child.start_byte, "rebound", target)) + elif t == "as_pattern": + # `with ... as fixture` / `except Exception as fixture` binds the + # alias in the surrounding (module) scope. + alias = child.child_by_field_name("alias") + if alias is not None: + events.append(_PythonBindingEvent( + alias.start_byte, "rebound", _read_text(alias, source))) + elif t == "delete_statement": + # `del pytest` / `del fixture` unbinds the name at this point, so + # a later decorator no longer names pytest vocabulary. Only plain + # identifiers count — `del d[k]` / `del obj.attr` do not rebind a + # module-level name. + for target_node in child.children: + if target_node.type == "identifier": + events.append(_PythonBindingEvent( + target_node.start_byte, "unbound", + _read_text(target_node, source))) + walk(child) + + walk(root) + events.sort(key=lambda e: e.start_byte) + return events + +def _is_pytest_decorator_noise( + deco_path: str | None, + pytest_bindings: list[_PythonBindingEvent], + byte_pos: int, +) -> bool: + """True when the decorator at `byte_pos` names an ambient pytest decorator. + + Matched by qualified path and by the pytest binding in force at the + decorator's source location, never by bare tail symbol: `@pytest.fixture`, + `@pt.fixture` (via `import pytest as pt`), and a bare `@fixture` rebound + from `from pytest import fixture` are pytest vocabulary, while a corpus's + own unimported `@fixture` — or one shadowed by an earlier local binding or + `del` — keeps its edge. + """ + if not deco_path: + return False + head = deco_path.partition(".")[0] + binding: str | None = None + for event in pytest_bindings: + if event.start_byte > byte_pos: + break + if event.name == head: + binding = event.kind # last event at or before the decorator wins + if binding == "imported_symbol": + return True + if binding in ("rebound", "unbound"): + return False + if binding == "module_alias": + suffix = deco_path[len(head):] + deco_path = f"pytest{suffix}" + if deco_path in _PYTEST_DECORATOR_PATHS: + return True + return any(deco_path.startswith(prefix) for prefix in _PYTEST_DECORATOR_PREFIXES) + +def _python_decorator_path(deco_node, source: bytes) -> str | None: + """Return the full dotted path of a Python `decorator` node. The Python twin of `_ts_decorator_name`, differing only in grammar node - names: `@traced` -> the identifier; `@retry(times=3)` -> the `function` of - the `call`; `@app.route("/")` / `@mod.deco` -> the `attribute` (the symbol - itself, not the module alias it is reached through). + names, but keeping the WHOLE path instead of the tail symbol: + `@pytest.fixture` -> "pytest.fixture"; `@retry(times=3)` -> "retry"; + `@app.route("/")` -> "app.route"; a bare `@fixture` -> "fixture". + `_python_decorator_name` derives the tail from this (the symbol that + resolves into a graph node); the full path is what decides whether the + decorator is ambient noise (#2732). """ for child in deco_node.children: if not child.is_named: @@ -5373,13 +5550,24 @@ def _python_decorator_name(deco_node, source: bytes) -> str | None: if target.type == "call": target = target.child_by_field_name("function") or target if target.type == "attribute": - attr = target.child_by_field_name("attribute") - return _read_text(attr, source) if attr else None + return _read_text(target, source) if target.type == "identifier": return _read_text(target, source) return None return None +def _python_decorator_name(deco_node, source: bytes) -> str | None: + """Return the head symbol of a Python `decorator` node. + + `@pytest.fixture` -> "fixture"; `@retry(times=3)` -> "retry"; + `@app.route("/")` -> "route"; `@fixture` -> "fixture". The tail of + `_python_decorator_path`. + """ + path = _python_decorator_path(deco_node, source) + if path is None: + return None + return path.rsplit(".", 1)[-1] + def _ts_decorator_name(deco_node, source: bytes) -> str | None: """Return the head symbol of a TS `decorator` node. diff --git a/tests/test_python_decorators.py b/tests/test_python_decorators.py index 9e7747ae2..77a47e722 100644 --- a/tests/test_python_decorators.py +++ b/tests/test_python_decorators.py @@ -219,6 +219,340 @@ def test_functools_wraps_does_not_rewire_onto_local_wraps(tmp_path): ) +def test_pytest_fixture_does_not_rewire_onto_local_fixture(tmp_path): + # #2732: qualified pytest decorators must not resolve to same-named corpus + # functions through the global decorator rewire, and no sourceless stub may + # be fabricated for a pytest decorator tail. + _write(tmp_path / "pkg" / "locals.py", + "def fixture():\n" + " return \"local helper\"\n" + "def parametrize():\n" + " return \"local parametrize\"\n") + cases = (("db", "@pytest.fixture\n"), + ("param", "@pytest.mark.parametrize(\"x\", [1, 2])\n")) + for name, deco in cases: + _write(tmp_path / "pkg" / f"case_{name}.py", + "import pytest\n" + "\n" + f"{deco}" + f"def {name}(x=None):\n" + " return x or {}\n") + paths = [tmp_path / "pkg" / "locals.py"] + paths += sorted(tmp_path.glob("pkg/case_*.py")) + r = extract(paths, cache_root=tmp_path) + for name, _ in cases: + source_nid = _func_nid(f"pkg/case_{name}.py", name) + for local in ("fixture", "parametrize"): + assert _func_nid("pkg/locals.py", local) not in _deco_edges( + r, source_nid), f"spurious decorator edge onto local {local}()" + for tail in ("fixture", "parametrize"): + assert not any(n["id"] == _make_id(tail) for n in r["nodes"]), f"stub {tail}()" + + +def test_corpus_owned_fixture_decorator_keeps_its_edge(tmp_path): + # #2732 guard against the naive fix: corpus-owned bare `@fixture` decorators + # must keep their edge — pytest suppression is by qualified path / import + # scope, never by folding the tail name into the noise set. + f = _write(tmp_path / "pkg" / "own.py", + "def fixture(fn):\n" + " return fn\n" + "\n" + "@fixture\n" + "def setup():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _func_nid("pkg/own.py", "fixture") in _deco_edges( + r, _func_nid("pkg/own.py", "setup")) + + +def test_local_decorator_shadows_pytest_import(tmp_path): + # A top-level `def fixture(fn)` rebinds the imported name: at the `@fixture` + # site it is the corpus's own decorator, so the scan must not suppress it. + f = _write(tmp_path / "pkg" / "own.py", + "from pytest import fixture\n" + "\n" + "def fixture(fn):\n" + " return fn\n" + "\n" + "@fixture\n" + "def setup():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _func_nid("pkg/own.py", "fixture") in _deco_edges( + r, _func_nid("pkg/own.py", "setup")) + + +def test_pytest_alias_in_with_shadows_import(tmp_path): + # A module-level `with ctx() as fixture` rebinds the imported name; the + # binding persists after the block. (`except ... as` is not modelled.) + f = _write(tmp_path / "pkg" / "with_alias.py", + "from pytest import fixture\n" + "\n" + "def ctx():\n" + " pass\n" + "with ctx() as fixture:\n" + " pass\n" + "\n" + "@fixture\n" + "def setup():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _make_id("fixture") in _deco_edges( + r, _func_nid("pkg/with_alias.py", "setup")), ( + "with-as rebinding must retain the decorator edge") + + +def test_pytest_import_is_valid_before_later_rebinding(tmp_path): + # Binding resolution is source-ordered, not whole-file: `@fixture` before a + # local `def fixture` is still pytest; after it, it is the local decorator. + f = _write(tmp_path / "pkg" / "case.py", + "from pytest import fixture\n" + "\n" + "@fixture\n" + "def test_before():\n" + " pass\n" + "\n" + "def fixture(fn):\n" + " return fn\n" + "\n" + "@fixture\n" + "def test_after():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert not _deco_edges(r, _func_nid("pkg/case.py", "test_before")), ( + "pre-rebinding @fixture must still be suppressed as pytest vocabulary") + assert _func_nid("pkg/case.py", "fixture") in _deco_edges( + r, _func_nid("pkg/case.py", "test_after")), ( + "post-rebinding @fixture must resolve to the local decorator") + + +def test_pytest_alias_shadowed_is_no_longer_suppressed(tmp_path): + # Same ordering rule for module aliases: `pt` is pytest until it is rebound, + # so the decorator before the rebinding is suppressed and the one after is not. + f = _write(tmp_path / "pkg" / "case.py", + "import pytest as pt\n" + "\n" + "@pt.fixture\n" + "def before():\n" + " pass\n" + "\n" + "pt = local_module\n" + "\n" + "@pt.fixture\n" + "def after():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert not _deco_edges(r, _func_nid("pkg/case.py", "before")), ( + "pre-rebinding @pt.fixture must be suppressed as pytest vocabulary") + assert _make_id("fixture") in _deco_edges( + r, _func_nid("pkg/case.py", "after")), ( + "post-rebinding @pt.fixture must not be suppressed") + + +def test_pytest_from_import_alias_is_suppressed(tmp_path): + # `from pytest import fixture as fx` binds the LOCAL name `fx`, which is what + # the import-scope scan must record (not the imported symbol `fixture`). + _write(tmp_path / "pkg" / "locals.py", + "def fixture():\n" + " return \"local helper\"\n") + f = _write(tmp_path / "pkg" / "case.py", + "from pytest import fixture as fx\n" + "\n" + "@fx\n" + "def db():\n" + " return {}\n") + r = extract([tmp_path / "pkg" / "locals.py", f], cache_root=tmp_path) + assert _func_nid("pkg/locals.py", "fixture") not in _deco_edges( + r, _func_nid("pkg/case.py", "db")), ( + "spurious decorator edge onto local fixture()") + + +def test_pytest_import_aliased_is_suppressed(tmp_path): + # `import pytest as pt` + `@pt.fixture` / `@pt.mark.parametrize` — the + # module alias must be recognised so the decorators are treated as pytest + # vocabulary rather than corpus code. + _write(tmp_path / "pkg" / "locals.py", + "def fixture():\n" + " return \"local helper\"\n" + "def parametrize():\n" + " return \"local parametrize\"\n") + f = _write(tmp_path / "pkg" / "case.py", + "import pytest as pt\n" + "\n" + "@pt.fixture\n" + "def db():\n" + " return {}\n" + "\n" + "@pt.mark.parametrize(\"x\", [1])\n" + "def test_x(x):\n" + " pass\n") + r = extract([tmp_path / "pkg" / "locals.py", f], cache_root=tmp_path) + assert _func_nid("pkg/locals.py", "fixture") not in _deco_edges( + r, _func_nid("pkg/case.py", "db")), ( + "spurious decorator edge onto local fixture()") + assert _func_nid("pkg/locals.py", "parametrize") not in _deco_edges( + r, _func_nid("pkg/case.py", "test_x")), ( + "spurious decorator edge onto local parametrize()") + assert not any(n["id"] == _make_id("fixture") for n in r["nodes"]) + + +def test_pytest_self_decorated_fixture_is_suppressed(tmp_path): + # The decorator expression is evaluated before the wrapped name is bound, so + # `@fixture def fixture()` still decorates pytest's fixture (source order). + f = _write(tmp_path / "pkg" / "case.py", + "from pytest import fixture\n" + "\n" + "@fixture\n" + "def fixture():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert not _deco_edges(r, _func_nid("pkg/case.py", "fixture")), ( + "self-decorated @fixture must be suppressed as pytest vocabulary") + + +def test_pytest_decorator_on_class_method_is_suppressed(tmp_path): + # Class-body decorators still match the module-level import; no sourceless + # `fixture` stub may be fabricated for corpus rewiring. + f = _write(tmp_path / "pkg" / "case.py", + "import pytest\n" + "\n" + "class Tests:\n" + " @pytest.fixture\n" + " def db(self):\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert not _deco_edges(r, _method_nid("pkg/case.py", "Tests", "db")), ( + "class-method @pytest.fixture must be suppressed as pytest vocabulary") + + +def test_pytest_decorator_on_nested_function_is_suppressed(tmp_path): + # The binding scan does not descend into function bodies, but the qualified + # path `pytest.fixture` matches the module-level import directly. + f = _write(tmp_path / "pkg" / "case.py", + "import pytest\n" + "\n" + "def make_test():\n" + " @pytest.fixture\n" + " def fixture():\n" + " pass\n" + " return fixture\n") + r = extract([f], cache_root=tmp_path) + assert not _deco_edges(r, _func_nid("pkg/case.py", "fixture")), ( + "nested @pytest.fixture must be suppressed as pytest vocabulary") + + +def test_pytest_module_alias_shadowed_by_for_target(tmp_path): + # Same scope model as `_python_module_bound_names`: `for pytest in ...` + # rebinds the module name, so the later decorator keeps its edge. + f = _write(tmp_path / "pkg" / "case.py", + "import pytest\n" + "\n" + "for pytest in [1, 2]:\n" + " pass\n" + "\n" + "@pytest.fixture\n" + "def setup():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _make_id("fixture") in _deco_edges( + r, _func_nid("pkg/case.py", "setup")), ( + "for-target rebinding must unsuppress the decorator") + + +def test_pytest_module_alias_shadowed_by_walrus(tmp_path): + # Same for the walrus operator: `(pytest := ...)` rebinds the module name + # before the decorator, so the decorator edge is retained. + f = _write(tmp_path / "pkg" / "case.py", + "import pytest\n" + "\n" + "(pytest := local_module)\n" + "\n" + "@pytest.fixture\n" + "def setup():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _make_id("fixture") in _deco_edges( + r, _func_nid("pkg/case.py", "setup")), ( + "walrus rebinding must unsuppress the decorator") + + +def test_stacked_pytest_and_custom_decorators(tmp_path): + # Suppression is per-decorator-node: the custom decorator edge must survive + # on either side of a suppressed @pytest.fixture. + f = _write(tmp_path / "pkg" / "case.py", + "import pytest\n" + "\n" + "def custom(fn):\n" + " return fn\n" + "\n" + "@custom\n" + "@pytest.fixture\n" + "def a():\n" + " pass\n" + "\n" + "@pytest.fixture\n" + "@custom\n" + "def b():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + for func in ("a", "b"): + edges = _deco_edges(r, _func_nid("pkg/case.py", func)) + assert _func_nid("pkg/case.py", "custom") in edges, ( + f"custom decorator on {func} must keep its edge") + assert "fixture" not in edges, ( + f"pytest decorator on {func} must be suppressed") + + +def test_unbound_pytest_qualified_decorator_keeps_edge(tmp_path): + # #2732 boundary: suppression needs binding evidence — an unimported + # `@pytest.fixture` where `pytest` is the corpus's own function keeps its edge. + f = _write(tmp_path / "pkg" / "case.py", + "def pytest():\n" + " return object()\n" + "\n" + "@pytest.fixture\n" + "def setup():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _make_id("fixture") in _deco_edges( + r, _func_nid("pkg/case.py", "setup")), ( + "unimported @pytest.fixture must not be assumed to be pytest") + + +def test_pytest_mark_import_with_local_parametrize(tmp_path): + # Root-name resolution governs: `mark` is bound from pytest, so `@mark.parametrize` + # is suppressed even when the corpus defines its own `parametrize`. + f = _write(tmp_path / "pkg" / "case.py", + "from pytest import mark\n" + "\n" + "def parametrize():\n" + " return 1\n" + "\n" + "@mark.parametrize(\"x\", [1, 2])\n" + "def test_x(x):\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert not _deco_edges(r, _func_nid("pkg/case.py", "test_x")), ( + "@mark.parametrize must be suppressed as pytest vocabulary") + + +def test_pytest_unbound_by_del_keeps_edge(tmp_path): + # `del pytest` removes the module binding, so a later @pytest.fixture is the + # corpus's own reference and keeps its decorator edge. + f = _write(tmp_path / "pkg" / "case.py", + "import pytest\n" + "\n" + "del pytest\n" + "\n" + "@pytest.fixture\n" + "def setup():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _make_id("fixture") in _deco_edges( + r, _func_nid("pkg/case.py", "setup")), ( + "del rebinding must unsuppress the decorator") + + def test_undecorated_function_emits_no_decorator_edge(tmp_path): f = _write(tmp_path / "pkg" / "plain.py", "def plain():\n"