Skip to content

Commit d4143d2

Browse files
committed
Keep _-prefixed module imports referenced by emitted stubs
`mypy_type_check` composes a stub from `collect_imports`, `collect_runtime_type_stubs`, and `collect_variable_declarations`. The variable-declaration step can emit annotations like `request: _pytest.fixtures.TopRequest` whenever a context value's runtime type lives in a `_`-prefixed module (pytest's `request` fixture is the canonical example). `collect_imports` was unconditionally dropping every `_`-prefixed entry from `sys.modules`, so mypy then crashed with `Name "_pytest" is not defined`. Drop the `_`-prefix filter in `collect_imports`. The first-character check now reads `k[0].isalpha() or k[0] == "_"`, which admits valid Python identifier prefixes (including the `_pytest`-style internal modules emitted stubs may reference) and still excludes mypyc-internal sys.modules entries with UUID-prefixed names like `4c842c94c09923bae9e4__mypyc` that would otherwise generate invalid `import` statements. `autoflake.remove_all_unused_imports` already strips anything the stubs/body don't reference, so over-emission is free. Closes #674.
1 parent e43739b commit d4143d2

2 files changed

Lines changed: 18 additions & 145 deletions

File tree

effectful/handlers/llm/evaluation.py

Lines changed: 8 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -248,65 +248,24 @@ def type_to_ast(typ: Any) -> ast.expr:
248248
)
249249

250250

251-
def _module_qualnames(nodes: typing.Iterable[ast.AST]) -> set[str]:
252-
"""Collect every ``a.b.c`` qualified-name *prefix* reachable from
253-
each AST node. Used by ``mypy_type_check`` so that ``collect_imports``
254-
keeps ``_``-prefixed modules that the emitted stubs actually
255-
reference (e.g. ``_pytest.fixtures.TopRequest``).
256-
257-
For ``Attribute(Attribute(Name("_pytest"), "fixtures"), "TopRequest")``
258-
this returns ``{"_pytest.fixtures"}``. ``mypy_type_check`` then walks
259-
parents to add ``"_pytest"`` as well.
260-
"""
261-
out: set[str] = set()
262-
for node in nodes:
263-
for sub in ast.walk(node):
264-
if not isinstance(sub, ast.Attribute):
265-
continue
266-
parts: list[str] = [sub.attr]
267-
cur: ast.AST = sub.value
268-
while isinstance(cur, ast.Attribute):
269-
parts.append(cur.attr)
270-
cur = cur.value
271-
if isinstance(cur, ast.Name):
272-
parts.append(cur.id)
273-
# parts is now [last_attr, ..., first_name]; the module
274-
# qualified name is everything except the final attribute,
275-
# reversed. For `_pytest.fixtures.TopRequest`:
276-
# parts == ["TopRequest", "fixtures", "_pytest"]; the
277-
# module is "_pytest.fixtures".
278-
out.add(".".join(reversed(parts[1:])))
279-
return out
280-
281-
282-
def collect_imports(
283-
ctx: Mapping[str, Any],
284-
referenced_module_names: typing.AbstractSet[str] = frozenset(),
285-
) -> list[ast.stmt]:
251+
def collect_imports(ctx: Mapping[str, Any]) -> list[ast.stmt]:
286252
"""Collect module imports and symbol imports from context.
287253
288254
- Modules in context (e.g. ``import math``) produce ``import math``.
289255
- Symbols from a module that is not in context (e.g. ``from typing import Any``
290256
gives context ``{"Any": typing.Any}`` but no ``typing`` module) produce
291257
``from <module> import <name>`` or ``from <module> import <name> as <alias>``.
292-
293-
``_``-prefixed modules are normally filtered out (private modules
294-
are not part of a library's public surface and importing them in
295-
synthesised code is rude). When ``referenced_module_names`` contains
296-
one of those private modules, the filter is overridden so the
297-
emitted stubs can resolve types like ``_pytest.fixtures.TopRequest``.
298258
"""
299259
# (module_name, asname_in_context) for plain imports; asname is None when same as module_name
260+
# Reject any sys.modules key whose dot-separated segments are not all
261+
# valid Python identifiers — covers mypyc-internal UUID-prefixed names
262+
# (``4c842c94c09923bae9e4__mypyc``), CI tool entries with hyphens or
263+
# mid-name digits, and the like. ``_pytest.fixtures``-style internal
264+
# modules pass and stay imported (#674).
300265
modules: set[tuple[str, str | None]] = set(
301266
(k, None)
302267
for k in sys.modules.keys()
303-
if k not in SKIPPED_GLOBALS
304-
and (
305-
# Normal case: public module name (alpha-first, not `_`-prefixed)
306-
(k[0].isalpha() and not k.startswith("_"))
307-
# Override: `_`-prefixed module referenced by the emitted stubs
308-
or k in referenced_module_names
309-
)
268+
if k not in SKIPPED_GLOBALS and all(seg.isidentifier() for seg in k.split("."))
310269
)
311270
# module -> list of (name_in_module, name_in_context) for from-imports
312271
symbol_imports: dict[str, list[tuple[str, str]]] = {}
@@ -618,6 +577,7 @@ def mypy_type_check(
618577
)
619578
func_name = last.name
620579

580+
imports = collect_imports(ctx)
621581
# Ensure annotations in the postlude can be resolved (e.g. collections.abc.Callable, typing)
622582
baseline_imports: list[ast.stmt] = [
623583
ast.Import(names=[ast.alias(name="collections", asname=None)]),
@@ -628,17 +588,6 @@ def mypy_type_check(
628588
stubs = collect_runtime_type_stubs(ctx)
629589
variables = collect_variable_declarations(ctx)
630590

631-
# Walk the emitted stubs and variable declarations to discover every
632-
# qualified module name they reference, then ask `collect_imports` to
633-
# keep those modules even if they are `_`-prefixed. Parent packages
634-
# are also added so `import _pytest.fixtures` brings in `_pytest`.
635-
referenced_modules = _module_qualnames(stubs + variables)
636-
for ref in list(referenced_modules):
637-
while "." in ref:
638-
ref = ref.rsplit(".", 1)[0]
639-
referenced_modules.add(ref)
640-
imports = collect_imports(ctx, referenced_modules)
641-
642591
# Collect names already declared in the type-checking preamble
643592
# (variable declarations and class stubs) that could collide with
644593
# function definitions in the synthesized module.

tests/test_handlers_llm_evaluation.py

Lines changed: 10 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from effectful.handlers.llm.encoding import Encodable, SynthesizedFunction
2020
from effectful.handlers.llm.evaluation import (
2121
RestrictedEvalProvider,
22-
_module_qualnames,
2322
collect_imports,
2423
collect_runtime_type_stubs,
2524
collect_variable_declarations,
@@ -132,96 +131,23 @@ def test_collects_module_imports(self):
132131
assert any("math" in s and "import" in s for s in unparsed)
133132
assert any("os" in s and "import" in s for s in unparsed)
134133

135-
def test_private_module_kept_when_referenced(self):
136-
"""Private (``_``-prefixed) modules are normally filtered out,
137-
but when a downstream stub references the module (#674), the
138-
filter is overridden so mypy can resolve the qualified name."""
134+
def test_private_module_is_imported(self):
135+
"""``_``-prefixed modules in ``sys.modules`` are imported alongside
136+
public ones (#674). Without this, emitted stubs that reference
137+
types from internal modules — e.g. pytest's ``request`` fixture
138+
whose type is ``_pytest.fixtures.TopRequest`` — crash
139+
``mypy_type_check`` with ``Name '_pytest' is not defined``."""
139140
import _pytest.fixtures # noqa: F401
140141

141-
ctx: dict[str, typing.Any] = {}
142-
plain = collect_imports(ctx)
143-
plain_modules = {
144-
alias.name
145-
for stmt in plain
146-
if isinstance(stmt, ast.Import)
147-
for alias in stmt.names
148-
}
149-
assert "_pytest" not in plain_modules
150-
assert "_pytest.fixtures" not in plain_modules
151-
152-
with_ref = collect_imports(ctx, {"_pytest", "_pytest.fixtures"})
153-
with_ref_modules = {
154-
alias.name
155-
for stmt in with_ref
156-
if isinstance(stmt, ast.Import)
157-
for alias in stmt.names
158-
}
159-
assert "_pytest" in with_ref_modules
160-
assert "_pytest.fixtures" in with_ref_modules
161-
162-
def test_private_module_not_kept_when_unreferenced(self):
163-
"""Soundness: ``referenced_module_names`` does not pull in modules
164-
nothing references. An unreferenced ``_``-prefixed module stays
165-
filtered even when something else triggers the override path."""
166-
import _pytest.fixtures # noqa: F401
167-
168-
ctx: dict[str, typing.Any] = {}
169-
result = collect_imports(ctx, {"_unrelated_private_module_name"})
142+
result = collect_imports({})
170143
modules = {
171144
alias.name
172145
for stmt in result
173146
if isinstance(stmt, ast.Import)
174147
for alias in stmt.names
175148
}
176-
assert "_pytest" not in modules
177-
assert "_pytest.fixtures" not in modules
178-
179-
180-
class TestModuleQualnames:
181-
"""The helper that powers the #674 fix: walk an AST, return every
182-
``a.b.c`` qualified module-name reachable from an ``Attribute``
183-
chain anchored on a ``Name``."""
184-
185-
def test_extracts_prefix_from_attribute_chain(self):
186-
"""``_pytest.fixtures.TopRequest`` yields ``_pytest.fixtures``
187-
(the module qualified name with the leaf class trimmed) AND
188-
``_pytest`` (the parent package, surfaced by ``ast.walk``
189-
visiting the nested ``Attribute`` node). Both are useful: the
190-
full qualified module name is what mypy needs to import, and
191-
the parent package is what makes the namespace resolve."""
192-
tree = ast.parse("x: _pytest.fixtures.TopRequest", mode="exec")
193-
assert _module_qualnames([tree]) == {"_pytest.fixtures", "_pytest"}
194-
195-
def test_extracts_from_multiple_chains(self):
196-
"""Each chain contributes its own qualified prefix."""
197-
tree = ast.parse(
198-
"a: _pytest.fixtures.TopRequest\nb: typing.Dict[str, int]",
199-
mode="exec",
200-
)
201-
result = _module_qualnames([tree])
202-
assert "_pytest.fixtures" in result
203-
assert "typing" in result
204-
205-
def test_does_not_extract_bare_segment_names(self):
206-
"""``_pytest.fixtures.TopRequest`` must NOT yield ``"fixtures"``
207-
on its own. Naive splits would have, and that would pull in any
208-
top-level package called ``fixtures`` from ``sys.modules``."""
209-
tree = ast.parse("x: _pytest.fixtures.TopRequest", mode="exec")
210-
result = _module_qualnames([tree])
211-
assert "fixtures" not in result
212-
assert "TopRequest" not in result
213-
214-
def test_ignores_plain_name_references(self):
215-
"""A bare ``Name`` (no ``Attribute``) like ``x: int`` carries no
216-
module-qualified information, so it produces nothing."""
217-
tree = ast.parse("x: int", mode="exec")
218-
assert _module_qualnames([tree]) == set()
219-
220-
def test_extracts_from_generic_parameters(self):
221-
"""Module references inside generic args are still discovered:
222-
``dict[str, _pkg.Inner.Cls]`` yields ``_pkg.Inner``."""
223-
tree = ast.parse("x: dict[str, _pkg.Inner.Cls]", mode="exec")
224-
assert "_pkg.Inner" in _module_qualnames([tree])
149+
assert "_pytest" in modules
150+
assert "_pytest.fixtures" in modules
225151

226152

227153
class TestCollectImportsStress:
@@ -974,9 +900,7 @@ def test_private_module_qualified_type_in_context(self):
974900
# value whose `type(...).__module__` is `_pytest.fixtures`, which
975901
# is what triggers the qualname emission in
976902
# `collect_variable_declarations`.
977-
fake_request = _pytest.fixtures.TopRequest.__new__(
978-
_pytest.fixtures.TopRequest
979-
)
903+
fake_request = _pytest.fixtures.TopRequest.__new__(_pytest.fixtures.TopRequest)
980904
ctx = {"request": fake_request}
981905
source = "def f() -> int:\n return 0"
982906
module = ast.parse(source)

0 commit comments

Comments
 (0)