diff --git a/src/griffe/agents/visitor.py b/src/griffe/agents/visitor.py index 2f346b17..7cafc8bc 100644 --- a/src/griffe/agents/visitor.py +++ b/src/griffe/agents/visitor.py @@ -131,6 +131,7 @@ def __init__( self.docstring_options: dict[str, Any] = docstring_options or {} self.lines_collection: LinesCollection = lines_collection or LinesCollection() self.modules_collection: ModulesCollection = modules_collection or ModulesCollection() + self.type_guarded: bool = False def _get_docstring(self, node: ast.AST, strict: bool = False) -> Docstring | None: value, lineno, endlineno = get_docstring(node, strict=strict) @@ -230,6 +231,7 @@ def visit_classdef(self, node: ast.ClassDef) -> None: docstring=self._get_docstring(node), decorators=decorators, bases=bases, # type: ignore[arg-type] + runtime=not self.type_guarded, ) self.current[node.name] = class_ self.current = class_ @@ -340,6 +342,7 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: returns=get_annotation(node.returns, parent=self.current), decorators=decorators, docstring=self._get_docstring(node), + runtime=not self.type_guarded, ) self.current[node.name] = function @@ -381,6 +384,7 @@ def visit_import(self, node: ast.Import) -> None: alias_path, lineno=node.lineno, endlineno=node.end_lineno, # type: ignore[attr-defined] + runtime=not self.type_guarded, ) def visit_importfrom(self, node: ast.ImportFrom) -> None: # noqa: WPS231 @@ -410,6 +414,7 @@ def visit_importfrom(self, node: ast.ImportFrom) -> None: # noqa: WPS231 alias_path, # type: ignore[arg-type] lineno=node.lineno, endlineno=node.end_lineno, # type: ignore[attr-defined] + runtime=not self.type_guarded, ) def handle_attribute( # noqa: WPS231 @@ -477,6 +482,7 @@ def handle_attribute( # noqa: WPS231 lineno=node.lineno, endlineno=node.end_lineno, # type: ignore[union-attr] docstring=docstring, + runtime=not self.type_guarded, ) attribute.labels |= labels parent[name] = attribute @@ -501,6 +507,20 @@ def visit_annassign(self, node: ast.AnnAssign) -> None: """ self.handle_attribute(node, get_annotation(node.annotation, parent=self.current)) + def visit_if(self, node: ast.If) -> None: + """Visit an "if" node. + + Parameters: + node: The node to visit. + """ + if isinstance(node.parent, (ast.Module, ast.ClassDef)): # type: ignore[attr-defined] + with suppress(KeyError): # unhandled AST nodes + condition = get_annotation(node.test, self.current) + if str(condition) in {"typing.TYPE_CHECKING", "TYPE_CHECKING"}: + self.type_guarded = True + self.generic_visit(node) + self.type_guarded = False + _patched = False diff --git a/src/griffe/dataclasses.py b/src/griffe/dataclasses.py index fb7a6b85..61aa39a5 100644 --- a/src/griffe/dataclasses.py +++ b/src/griffe/dataclasses.py @@ -306,6 +306,7 @@ def __init__( *, lineno: int | None = None, endlineno: int | None = None, + runtime: bool = True, docstring: Docstring | None = None, parent: Module | Class | None = None, lines_collection: LinesCollection | None = None, @@ -317,6 +318,7 @@ def __init__( name: The object name, as declared in the code. lineno: The object starting line, or None for modules. Lines start at 1. endlineno: The object ending line (inclusive), or None for modules. + runtime: Whether this object is present at runtime or not. docstring: The object docstring. parent: The object parent. lines_collection: A collection of source code lines. @@ -332,6 +334,7 @@ def __init__( self.imports: dict[str, str] = {} self.exports: set[str] | None = None self.aliases: dict[str, Alias] = {} + self.runtime: bool = runtime self._lines_collection: LinesCollection | None = lines_collection self._modules_collection: ModulesCollection | None = modules_collection @@ -366,7 +369,8 @@ def member_is_exported(self, member: Object | Alias, explicitely: bool = True) - By exported, we mean that the object is included in the `__all__` attribute of its parent module or class. When `_all__` is not defined, we consider the member to be *implicitely* exported, - unless it's a module and it was not imported. + unless it's a module and it was not imported, + and unless it's not defined at runtime. Parameters: member: The member to verify. @@ -375,6 +379,8 @@ def member_is_exported(self, member: Object | Alias, explicitely: bool = True) - Returns: True or False. """ + if not member.runtime: + return False if self.exports is None: return not explicitely and (member.is_alias or not member.is_module or member.name in self.imports) return member.name in self.exports @@ -756,6 +762,7 @@ def __init__( *, lineno: int | None = None, endlineno: int | None = None, + runtime: bool = True, parent: Module | Class | None = None, ) -> None: """Initialize the alias. @@ -766,6 +773,7 @@ def __init__( If it's an object, or even another alias, the target is immediately set. lineno: The alias starting line number. endlineno: The alias ending line number. + runtime: Whether this alias is present at runtime or not. parent: The alias parent. """ self.name: str = name @@ -780,6 +788,7 @@ def __init__( target.aliases[self.path] = self self.alias_lineno: int | None = lineno self.alias_endlineno: int | None = endlineno + self.runtime: bool = runtime self._parent: Module | Class | None = parent self._passed_through: bool = False diff --git a/tests/test_visitor.py b/tests/test_visitor.py index 4b02a640..207cbad1 100644 --- a/tests/test_visitor.py +++ b/tests/test_visitor.py @@ -1,8 +1,11 @@ """Test visit mechanisms.""" +from textwrap import dedent + import pytest -from tests.helpers import temporary_visited_module +from griffe.loader import GriffeLoader +from tests.helpers import temporary_pypackage, temporary_visited_module # import sys # import hypothesmith as hs @@ -65,3 +68,34 @@ def test_building_value_from_nodes(expression): with temporary_visited_module(f"a = {expression}") as module: assert "a" in module.members assert module["a"].value == expression + + +def test_not_defined_at_runtime(): + """Assert that objects not defined at runtime are not added to wildcards expansions.""" + with temporary_pypackage("package", ["module_a.py", "module_b.py", "module_c.py"]) as tmp_package: + tmp_package.path.joinpath("__init__.py").write_text("from package.module_a import *") + tmp_package.path.joinpath("module_a.py").write_text( + dedent( + """ + import typing + from typing import TYPE_CHECKING + + from package.module_b import CONST_B + from package.module_c import CONST_C + + if typing.TYPE_CHECKING: # always false + from package.module_b import TYPE_B + if TYPE_CHECKING: # always false + from package.module_c import TYPE_C + """ + ) + ) + tmp_package.path.joinpath("module_b.py").write_text("CONST_B = 'hi'\nTYPE_B = str") + tmp_package.path.joinpath("module_c.py").write_text("CONST_C = 'ho'\nTYPE_C = str") + loader = GriffeLoader(search_paths=[tmp_package.tmpdir]) + package = loader.load_module(tmp_package.name) + loader.resolve_aliases() + assert "CONST_B" in package.members + assert "CONST_C" in package.members + assert "TYPE_B" not in package.members + assert "TYPE_C" not in package.members