Skip to content

Commit 1b419c5

Browse files
stroxlermeta-codesync[bot]
authored andcommitted
Add relative import support to static python visitors
Summary: This is relevant to a pytree benchmark we want to run, becasue some of the dependencies in numpy use relative imports. Since a *lot* of perf-sensitive code will use numpy in ML, data science, and other compute-heavy scenarios, anything relevant to numpy seems like a pretty good feature to have. The test coverage is pretty exhaustive, it might be overkill Reviewed By: DinoV Differential Revision: D96385657 fbshipit-source-id: 78e39c19df4b010ae39655343847b6ac95ce977e
1 parent 10246e6 commit 1b419c5

6 files changed

Lines changed: 329 additions & 8 deletions

File tree

cinderx/PythonLib/cinderx/compiler/static/declaration_visitor.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,13 @@ def visitImport(self, node: Import) -> None:
261261
)
262262

263263
def visitImportFrom(self, node: ImportFrom) -> None:
264-
mod_name = node.module
265-
if not mod_name or node.level:
266-
raise NotImplementedError("relative imports aren't supported")
264+
if node.level:
265+
mod_name = self._resolve_relative_import(node)
266+
else:
267+
mod_name = node.module
268+
if not mod_name:
269+
self.syntax_error("empty module name in import", node)
270+
return
267271
for name in node.names:
268272
child_name = name.asname or name.name
269273
self.module.declare_import(

cinderx/PythonLib/cinderx/compiler/static/type_binder.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1755,9 +1755,13 @@ def visitImport(self, node: Import) -> None:
17551755
self.declare_local(declaration_name, typ)
17561756

17571757
def visitImportFrom(self, node: ImportFrom) -> None:
1758-
mod_name = node.module
1759-
if node.level or not mod_name:
1760-
raise NotImplementedError("relative imports aren't supported")
1758+
if node.level:
1759+
mod_name = self._resolve_relative_import(node)
1760+
else:
1761+
mod_name = node.module
1762+
if not mod_name:
1763+
self.syntax_error("empty module name in import", node)
1764+
return
17611765

17621766
if mod_name == "__static__":
17631767
for alias in node.names:

cinderx/PythonLib/cinderx/compiler/static/visitor.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from __future__ import annotations
55

6-
from ast import AST
6+
from ast import AST, ImportFrom
77
from contextlib import contextmanager, nullcontext
88
from typing import ContextManager, Generator, Generic, TYPE_CHECKING, TypeVar
99

@@ -81,6 +81,35 @@ def error_context(self, node: AST | None) -> ContextManager[None]:
8181
return nullcontext()
8282
return self.error_sink.error_context(self.filename, node)
8383

84+
def _resolve_relative_import(self, node: ImportFrom) -> str:
85+
"""Resolve a relative import to an absolute module name.
86+
87+
For __init__ modules, level=1 means "this package" (no stripping).
88+
For non-__init__ modules, level=1 means "parent package" (strip one).
89+
Each additional level strips one more package component.
90+
"""
91+
level = node.level
92+
parts = self.module_name.split(".")
93+
# __init__ modules ARE the package, so relative imports start from
94+
# the package itself rather than its parent.
95+
is_package = self.filename.endswith("__init__.py")
96+
strip = level if not is_package else level - 1
97+
if strip > len(parts):
98+
self.syntax_error(
99+
"attempted relative import beyond top-level package", node
100+
)
101+
return ""
102+
# Copy with list() when strip == 0 to avoid mutating `parts` via append below.
103+
base_parts = parts[: len(parts) - strip] if strip > 0 else list(parts)
104+
if node.module:
105+
base_parts.append(node.module)
106+
result = ".".join(base_parts)
107+
if not result:
108+
self.syntax_error(
109+
"attempted relative import beyond top-level package", node
110+
)
111+
return result
112+
84113
@contextmanager
85114
def temporary_error_sink(self, sink: ErrorSink) -> Generator[None, None, None]:
86115
orig_sink = self.error_sink

cinderx/PythonLib/test_cinderx/test_compiler/test_static/decl_visitor.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,101 @@ def import_module(self, name: str, optimize: int) -> ModuleTable:
295295
compiler.compile(
296296
"a", "a.py", ast.parse(dedent(codestr)), codestr, optimize=1
297297
)
298+
299+
# --- Relative import tests ---
300+
301+
def test_relative_import_preserves_static_type(self) -> None:
302+
"""from .foo import C in pkg.mod should resolve C statically,
303+
producing INVOKE_FUNCTION bytecode instead of a dynamic call."""
304+
foo_code = """
305+
class C:
306+
def f(self) -> int:
307+
return 42
308+
"""
309+
mod_code = """
310+
from .foo import C
311+
312+
def g():
313+
x = C()
314+
return x.f()
315+
"""
316+
comp = self.compiler(**{"pkg.foo": foo_code, "pkg.mod": mod_code})
317+
f = self.find_code(comp.compile_module("pkg.mod"), "g")
318+
self.assertInBytecode(f, "INVOKE_FUNCTION", ((("pkg.foo", "C"), "f"), 1))
319+
320+
def test_relative_import_multi_level_preserves_static_type(self) -> None:
321+
"""from ..foo import C in pkg.sub.mod should resolve to pkg.foo.C."""
322+
foo_code = """
323+
class C:
324+
def f(self) -> int:
325+
return 42
326+
"""
327+
mod_code = """
328+
from ..foo import C
329+
330+
def g():
331+
x = C()
332+
return x.f()
333+
"""
334+
comp = self.compiler(**{"pkg.foo": foo_code, "pkg.sub.mod": mod_code})
335+
f = self.find_code(comp.compile_module("pkg.sub.mod"), "g")
336+
self.assertInBytecode(f, "INVOKE_FUNCTION", ((("pkg.foo", "C"), "f"), 1))
337+
338+
def test_relative_import_from_dot_no_module(self) -> None:
339+
"""from . import foo in pkg.mod should resolve foo as a module,
340+
and chained attribute access foo.f() should produce INVOKE_FUNCTION."""
341+
pkg_code = """
342+
pass
343+
"""
344+
foo_code = """
345+
def f(x: int) -> int:
346+
return x
347+
"""
348+
mod_code = """
349+
from . import foo
350+
351+
def g():
352+
return foo.f(1)
353+
"""
354+
comp = self.compiler(
355+
**{"pkg": pkg_code, "pkg.foo": foo_code, "pkg.mod": mod_code}
356+
)
357+
f = self.find_code(comp.compile_module("pkg.mod"), "g")
358+
self.assertInBytecode(f, "INVOKE_FUNCTION", ((("pkg.foo",), "f"), 1))
359+
360+
def test_relative_import_in_function_scope(self) -> None:
361+
"""Function-scope relative import should work via type_binder path."""
362+
foo_code = """
363+
class C:
364+
def f(self) -> int:
365+
return 42
366+
"""
367+
mod_code = """
368+
def g():
369+
from .foo import C
370+
x = C()
371+
return x.f()
372+
"""
373+
comp = self.compiler(**{"pkg.foo": foo_code, "pkg.mod": mod_code})
374+
f = self.find_code(comp.compile_module("pkg.mod"), "g")
375+
self.assertInBytecode(f, "INVOKE_FUNCTION", ((("pkg.foo", "C"), "f"), 1))
376+
377+
def test_relative_import_type_checking(self) -> None:
378+
"""Relative import under TYPE_CHECKING should resolve for type annotations."""
379+
foo_code = """
380+
class C:
381+
def f(self) -> int:
382+
return 42
383+
"""
384+
mod_code = """
385+
from typing import TYPE_CHECKING
386+
387+
if TYPE_CHECKING:
388+
from .foo import C
389+
390+
def g(x: C):
391+
return x.f()
392+
"""
393+
comp = self.compiler(**{"pkg.foo": foo_code, "pkg.mod": mod_code})
394+
f = self.find_code(comp.compile_module("pkg.mod"), "g")
395+
self.assertInBytecode(f, "INVOKE_METHOD", ((("pkg.foo", "C"), "f"), 0))

cinderx/PythonLib/test_cinderx/test_compiler/test_static/imports.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,108 @@
22

33
# pyre-strict
44

5+
import ast
6+
import unittest
7+
58
from cinderx.compiler.pycodegen import PythonCodeGenerator
69
from cinderx.compiler.static.types import TypedSyntaxError
10+
from cinderx.compiler.static.visitor import GenericVisitor
711

812
from .common import StaticTestBase
913

1014

15+
def _make_import_from(code: str) -> ast.ImportFrom:
16+
"""Parse a single 'from ... import ...' statement into an ImportFrom node."""
17+
mod = ast.parse(code)
18+
node = mod.body[0]
19+
assert isinstance(node, ast.ImportFrom)
20+
return node
21+
22+
23+
class ResolveRelativeImportTests(unittest.TestCase):
24+
"""Unit tests for GenericVisitor._resolve_relative_import name resolution.
25+
26+
These test the name manipulation logic directly, without needing the full
27+
static compiler infrastructure. This lets us test __init__.py handling
28+
which the TestCompiler infra can't exercise (its _get_filename never
29+
produces __init__.py filenames).
30+
"""
31+
32+
def _resolve(self, module_name: str, filename: str, import_code: str) -> str:
33+
"""Call _resolve_relative_import with the given module context."""
34+
node = _make_import_from(import_code)
35+
# Create a minimal mock that has the attributes _resolve_relative_import needs:
36+
# module_name, filename, and syntax_error.
37+
visitor = object.__new__(GenericVisitor)
38+
visitor.module_name = module_name
39+
visitor.filename = filename
40+
errors: list[str] = []
41+
# pyre-ignore[8]: Attribute has type `(self: GenericVisitor[TVisitRet], msg: str, node: AST) -> None`
42+
visitor.syntax_error = lambda msg, node: errors.append(msg)
43+
result = visitor._resolve_relative_import(node)
44+
if errors:
45+
raise ValueError(errors[0])
46+
return result
47+
48+
# --- Non-__init__ modules (regular .py files) ---
49+
50+
def test_single_dot_from_submodule(self) -> None:
51+
# from .foo import C in pkg.mod -> pkg.foo
52+
result = self._resolve("pkg.mod", "mod.py", "from .foo import C")
53+
self.assertEqual(result, "pkg.foo")
54+
55+
def test_single_dot_no_module(self) -> None:
56+
# from . import foo in pkg.mod -> base module is "pkg",
57+
# then "foo" is resolved as a child of pkg by visitImportFrom.
58+
result = self._resolve("pkg.mod", "mod.py", "from . import foo")
59+
self.assertEqual(result, "pkg")
60+
61+
def test_double_dot(self) -> None:
62+
# from ..foo import C in pkg.sub.mod -> pkg.foo
63+
result = self._resolve("pkg.sub.mod", "mod.py", "from ..foo import C")
64+
self.assertEqual(result, "pkg.foo")
65+
66+
def test_double_dot_no_module(self) -> None:
67+
# from .. import foo in pkg.sub.mod -> base module is "pkg",
68+
# then "foo" is resolved as a child of pkg by visitImportFrom.
69+
result = self._resolve("pkg.sub.mod", "mod.py", "from .. import foo")
70+
self.assertEqual(result, "pkg")
71+
72+
def test_beyond_top_level(self) -> None:
73+
# from ...foo import C in pkg.mod -> error (only 2 parts, 3 dots)
74+
with self.assertRaisesRegex(ValueError, "beyond top-level"):
75+
self._resolve("pkg.mod", "mod.py", "from ...foo import C")
76+
77+
# --- __init__.py modules (package __init__ files) ---
78+
79+
def test_init_single_dot(self) -> None:
80+
# from . import foo in pkg/__init__.py (module_name="pkg") -> base is "pkg",
81+
# then "foo" is resolved as a child of pkg by visitImportFrom.
82+
result = self._resolve("pkg", "__init__.py", "from . import foo")
83+
self.assertEqual(result, "pkg")
84+
85+
def test_init_single_dot_with_module(self) -> None:
86+
# from .sub import C in pkg/__init__.py (module_name="pkg") -> pkg.sub
87+
result = self._resolve("pkg", "__init__.py", "from .sub import C")
88+
self.assertEqual(result, "pkg.sub")
89+
90+
def test_init_double_dot(self) -> None:
91+
# from .. import foo in pkg.sub/__init__.py (module_name="pkg.sub") -> base is "pkg",
92+
# then "foo" is resolved as a child of pkg by visitImportFrom.
93+
result = self._resolve("pkg.sub", "__init__.py", "from .. import foo")
94+
self.assertEqual(result, "pkg")
95+
96+
def test_init_double_dot_with_module(self) -> None:
97+
# from ..other import C in pkg.sub/__init__.py -> pkg.other
98+
result = self._resolve("pkg.sub", "__init__.py", "from ..other import C")
99+
self.assertEqual(result, "pkg.other")
100+
101+
def test_init_beyond_top_level(self) -> None:
102+
# from .. import foo in top-level pkg/__init__.py -> error
103+
with self.assertRaisesRegex(ValueError, "beyond top-level"):
104+
self._resolve("pkg", "__init__.py", "from .. import foo")
105+
106+
11107
class ImportTests(StaticTestBase):
12108
def test_unknown_import_with_fallback_is_not_allowed(self) -> None:
13109
codestr = """

cinderx/PythonLib/test_cinderx/test_compiler/test_static/module.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# pyre-strict
44

55
from cinderx.compiler.static.module_table import ModuleTableException
6-
from cinderx.compiler.static.types import ModuleInstance
6+
from cinderx.compiler.static.types import Class, ModuleInstance, TypedSyntaxError
77

88
from .common import get_child, StaticTestBase, TestCompiler
99

@@ -314,6 +314,96 @@ class A:
314314
compiler = self.decl_visit(**{"a": acode, "b": bcode})
315315
compiler.compile_module("a")
316316

317+
# --- Relative import resolution tests ---
318+
319+
def test_relative_import_from_dot_module(self) -> None:
320+
"""from .foo import X in pkg.mod resolves to pkg.foo.X"""
321+
foo_code = """
322+
class C:
323+
def f(self) -> int:
324+
return 42
325+
"""
326+
mod_code = """
327+
from .foo import C
328+
"""
329+
compiler = self.decl_visit(**{"pkg.foo": foo_code, "pkg.mod": mod_code})
330+
331+
c = get_child(compiler.modules["pkg.mod"], "C")
332+
self.assertIsNotNone(c)
333+
assert isinstance(c, Class)
334+
self.assertEqual(c.type_name.module, "pkg.foo")
335+
self.assertEqual(c.type_name.qualname, "C")
336+
337+
def test_relative_import_from_dot_no_module(self) -> None:
338+
"""from . import foo in pkg.mod resolves to pkg.foo"""
339+
foo_code = """
340+
def f(x: int) -> int:
341+
return x
342+
"""
343+
mod_code = """
344+
from . import foo
345+
"""
346+
pkg_code = """
347+
pass
348+
"""
349+
compiler = self.decl_visit(
350+
**{"pkg": pkg_code, "pkg.foo": foo_code, "pkg.mod": mod_code}
351+
)
352+
353+
foo = get_child(compiler.modules["pkg.mod"], "foo")
354+
self.assertIsNotNone(foo)
355+
assert isinstance(foo, ModuleInstance)
356+
self.assertEqual(foo.module_name, "pkg.foo")
357+
358+
def test_relative_import_multi_level(self) -> None:
359+
"""from .. import foo in pkg.sub.mod resolves to pkg.foo"""
360+
foo_code = """
361+
def f(x: int) -> int:
362+
return x
363+
"""
364+
mod_code = """
365+
from .. import foo
366+
"""
367+
pkg_code = """
368+
pass
369+
"""
370+
compiler = self.decl_visit(
371+
**{"pkg": pkg_code, "pkg.foo": foo_code, "pkg.sub.mod": mod_code}
372+
)
373+
374+
foo = get_child(compiler.modules["pkg.sub.mod"], "foo")
375+
self.assertIsNotNone(foo)
376+
assert isinstance(foo, ModuleInstance)
377+
self.assertEqual(foo.module_name, "pkg.foo")
378+
379+
def test_relative_import_multi_level_with_module(self) -> None:
380+
"""from ..other import C in pkg.sub.mod resolves to pkg.other.C"""
381+
other_code = """
382+
class C:
383+
def f(self) -> int:
384+
return 42
385+
"""
386+
mod_code = """
387+
from ..other import C
388+
"""
389+
compiler = self.decl_visit(**{"pkg.other": other_code, "pkg.sub.mod": mod_code})
390+
391+
c = get_child(compiler.modules["pkg.sub.mod"], "C")
392+
self.assertIsNotNone(c)
393+
assert isinstance(c, Class)
394+
self.assertEqual(c.type_name.module, "pkg.other")
395+
self.assertEqual(c.type_name.qualname, "C")
396+
397+
def test_relative_import_beyond_top_level(self) -> None:
398+
"""Relative import with too many dots should error."""
399+
mod_code = """
400+
from ...foo import C
401+
"""
402+
with self.assertRaisesRegex(
403+
TypedSyntaxError, "attempted relative import beyond top-level package"
404+
):
405+
self.decl_visit(**{"pkg.mod": mod_code})
406+
317407
def test_actual_cyclic_reference(self) -> None:
318408
acode = """
319409
from b import B

0 commit comments

Comments
 (0)