From 464c39eaa812a927190469b18bd910e95e3c1d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Wed, 12 Jan 2022 14:01:22 +0100 Subject: [PATCH] fix: Fix relative to absolute import conversion --- src/griffe/agents/nodes.py | 26 ++++++++++++++++++++++++ src/griffe/agents/visitor.py | 34 +++++++++++++------------------ src/griffe/dataclasses.py | 2 +- tests/test_nodes.py | 39 ++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 tests/test_nodes.py diff --git a/src/griffe/agents/nodes.py b/src/griffe/agents/nodes.py index 5452a9e4..60e2c5ab 100644 --- a/src/griffe/agents/nodes.py +++ b/src/griffe/agents/nodes.py @@ -31,6 +31,7 @@ from ast import Gt as NodeGt from ast import GtE as NodeGtE from ast import IfExp as NodeIfExp +from ast import ImportFrom as NodeImportFrom from ast import In as NodeIn from ast import Invert as NodeInvert from ast import Is as NodeIs @@ -64,6 +65,7 @@ from ast import UnaryOp as NodeUnaryOp from ast import USub as NodeUSub from ast import Yield as NodeYield +from ast import alias as NodeAlias from ast import arguments as NodeArguments from ast import comprehension as NodeComprehension from ast import keyword as NodeKeyword @@ -1176,3 +1178,27 @@ def get_parameter_default(node: AST, filepath: Path, lines_collection: LinesColl return lines_collection[filepath][node.lineno - 1][node.col_offset : node.end_col_offset] # type: ignore[attr-defined] # TODO: handle multiple line defaults return None + + +# ========================================================== +# relative imports +def relative_to_absolute(node: NodeImportFrom, name: NodeAlias, current_module: Module) -> str: + """Convert a relative import path to an absolute one. + + Parameters: + node: The "from ... import ..." AST node. + name: The imported name. + current_module: The module in which the import happens. + + Returns: + The absolute import path. + """ + level = node.level + if level > 0 and current_module.is_package or current_module.is_subpackage: + level -= 1 + while level > 0 and current_module.parent is not None: + current_module = current_module.parent # type: ignore[assignment] + level -= 1 + base = current_module.path + "." if node.level > 0 else "" + node_module = node.module + "." if node.module else "" + return base + node_module + name.name diff --git a/src/griffe/agents/visitor.py b/src/griffe/agents/visitor.py index c7aff756..0c7dadca 100644 --- a/src/griffe/agents/visitor.py +++ b/src/griffe/agents/visitor.py @@ -26,6 +26,7 @@ get_parameter_default, get_value, parse__all__, + relative_to_absolute, ) from griffe.collections import LinesCollection, ModulesCollection from griffe.dataclasses import ( @@ -388,27 +389,20 @@ def visit_importfrom(self, node: ast.ImportFrom) -> None: # noqa: WPS231 node: The node to visit. """ for name in node.names: - alias_name = name.asname or name.name - level = node.level - module_path = node.module - if level > 0: - if module_path is None: - level -= 1 - parent: Module = self.current.module - if parent.is_package or parent.is_subpackage: - level -= 1 - while level > 0: - parent = parent.parent # type: ignore[assignment] - level -= 1 - if module_path: - module_path = f"{parent.path}.{module_path}" - else: - module_path = parent.path - if alias_name == "*": - alias_name = module_path.replace(".", "/") + "/*" # type: ignore[union-attr] - alias_path = module_path + if not node.module and node.level == 1: + if not name.asname: + # special case: when being in `a` and doing `from . import b`, + # we are effectively creating a member `b` in `a` that is pointing to `a.b` + # -> cyclic alias! in that case, we just skip it, as both the member and module + # have the same name and can be accessed the same way + continue + + if name.name == "*": + alias_name = node.module.replace(".", "/") + "/*" # type: ignore[union-attr] + alias_path = node.module else: - alias_path = f"{module_path}.{name.name}" + alias_name = name.asname or name.name + alias_path = relative_to_absolute(node, name, self.current.module) self.current.imports[alias_name] = alias_path self.current[alias_name] = Alias( alias_name, diff --git a/src/griffe/dataclasses.py b/src/griffe/dataclasses.py index 0a7f3366..e602ad97 100644 --- a/src/griffe/dataclasses.py +++ b/src/griffe/dataclasses.py @@ -915,7 +915,7 @@ def is_init_module(self) -> bool: True or False. """ try: - return self.filepath.name == "__init__.py" + return self.filepath.name.split(".", 1)[0] == "__init__" except BuiltinModuleError: return False diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 00000000..198ca22e --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,39 @@ +"""Test nodes utilities.""" + +from ast import PyCF_ONLY_AST + +import pytest + +from griffe.agents.nodes import relative_to_absolute +from tests.helpers import module_vtree + + +@pytest.mark.parametrize( + ("code", "path", "is_package", "expected"), + [ + ("from . import b", "a", False, "a.b"), + ("from . import c", "a.b", False, "a.c"), + ("from . import d", "a.b.c", False, "a.b.d"), + ("from .c import d", "a", False, "a.c.d"), + ("from .c import d", "a.b", False, "a.c.d"), + ("from .b import c", "a.b", True, "a.b.b.c"), + ("from .. import e", "a.c.d.i", False, "a.c.e"), + ("from ..d import e", "a.c.d.i", False, "a.c.d.e"), + ("from ... import f", "a.c.d.i", False, "a.f"), + ("from ...b import f", "a.c.d.i", False, "a.b.f"), + ("from ...c.d import e", "a.c.d.i", False, "a.c.d.e"), + ], +) +def test_relative_to_absolute_imports(code, path, is_package, expected): + """Check if relative imports are correctly converted to absolute ones. + + Parameters: + code: The parametrized module code. + path: The parametrized module path. + is_package: Whether the module is a package (or subpackage) (parametrized). + expected: The parametrized expected absolute path. + """ + node = compile(code, mode="exec", filename="<>", flags=PyCF_ONLY_AST).body[0] + module = module_vtree(path, leaf_package=is_package, return_leaf=True) + for name in node.names: + assert relative_to_absolute(node, name, module) == expected