Skip to content

Commit 3b31b58

Browse files
committed
feat(parser): expand Python from X import * in import_map
Add three helpers: - `_resolve_star_imports`: walks top-level `import_from_statement` nodes, detects `wildcard_import`, resolves the source module to a file, and merges the exported names into the caller's import_map. - `_get_exported_names`: returns the public names a module exports, respecting `__all__` when present. Caches results per resolved path with a threading lock so concurrent parses share work safely. - `_extract_dunder_all`: parses `__all__ = [...]` at module scope. Wires star-import expansion into `parse_bytes` and the notebook concat path. Enables resolution of calls that come in via wildcard imports (e.g. `from constants import *` then `use_constant()`).
1 parent 0cfc5ff commit 3b31b58

1 file changed

Lines changed: 106 additions & 0 deletions

File tree

code_review_graph/parser.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import json
1111
import logging
1212
import re
13+
import threading
1314
from dataclasses import dataclass, field
1415
from pathlib import Path
1516
from typing import NamedTuple, Optional
@@ -625,9 +626,11 @@ def __init__(self) -> None:
625626
self._parsers: dict[str, object] = {}
626627
self._module_file_cache: dict[str, Optional[str]] = {}
627628
self._export_symbol_cache: dict[str, Optional[str]] = {}
629+
self._star_export_cache: dict[str, set[str]] = {}
628630
self._tsconfig_resolver = TsconfigResolver()
629631
# Per-parse cache of Dart pubspec root lookups; see #87
630632
self._dart_pubspec_cache: dict[tuple[str, str], Optional[Path]] = {}
633+
self._lock = threading.Lock()
631634

632635
def _get_parser(self, language: str): # type: ignore[arg-type]
633636
if language not in self._parsers:
@@ -708,6 +711,12 @@ def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[E
708711
tree.root_node, language, source,
709712
)
710713

714+
# Expand Python `from X import *` into individual import_map entries.
715+
if language == "python":
716+
self._resolve_star_imports(
717+
tree.root_node, file_path_str, language, import_map,
718+
)
719+
711720
# Walk the tree
712721
self._extract_from_tree(
713722
tree.root_node, source, language, file_path_str, nodes, edges,
@@ -1139,6 +1148,10 @@ def _parse_notebook_cells(
11391148
import_map, defined_names = self._collect_file_scope(
11401149
tree.root_node, lang, concat_bytes,
11411150
)
1151+
if lang == "python":
1152+
self._resolve_star_imports(
1153+
tree.root_node, file_path_str, lang, import_map,
1154+
)
11421155
self._extract_from_tree(
11431156
tree.root_node, concat_bytes, lang,
11441157
file_path_str, all_nodes, all_edges,
@@ -3494,6 +3507,99 @@ def _extract_solidity_constructs(
34943507

34953508
return False
34963509

3510+
def _resolve_star_imports(
3511+
self,
3512+
root,
3513+
file_path: str,
3514+
language: str,
3515+
import_map: dict[str, str],
3516+
_resolving: Optional[frozenset[str]] = None,
3517+
) -> None:
3518+
"""Expand ``from X import *`` into individual import_map entries."""
3519+
if _resolving is None:
3520+
_resolving = frozenset()
3521+
for child in root.children:
3522+
if child.type != "import_from_statement":
3523+
continue
3524+
has_wildcard = False
3525+
module = None
3526+
for sub in child.children:
3527+
if sub.type == "wildcard_import":
3528+
has_wildcard = True
3529+
elif sub.type == "dotted_name" and module is None:
3530+
module = sub.text.decode("utf-8", errors="replace")
3531+
if not has_wildcard or not module:
3532+
continue
3533+
resolved = self._resolve_module_to_file(
3534+
module, file_path, language,
3535+
)
3536+
if not resolved or resolved in _resolving:
3537+
continue
3538+
exported = self._get_exported_names(
3539+
resolved, language, _resolving | {resolved},
3540+
)
3541+
for name in exported:
3542+
if name not in import_map:
3543+
import_map[name] = module
3544+
3545+
def _get_exported_names(
3546+
self,
3547+
resolved_path: str,
3548+
language: str,
3549+
_resolving: frozenset[str] = frozenset(),
3550+
) -> set[str]:
3551+
"""Return the public names exported by a module file.
3552+
3553+
Double-check locking: check cache, do I/O outside lock, store under lock.
3554+
"""
3555+
if resolved_path in self._star_export_cache:
3556+
return self._star_export_cache[resolved_path]
3557+
try:
3558+
source = Path(resolved_path).read_bytes()
3559+
except (OSError, PermissionError):
3560+
return set()
3561+
parser = self._get_parser(language)
3562+
if not parser:
3563+
return set()
3564+
tree = parser.parse(source) # type: ignore[union-attr]
3565+
all_names = self._extract_dunder_all(tree.root_node)
3566+
if all_names is not None:
3567+
with self._lock:
3568+
self._star_export_cache[resolved_path] = all_names
3569+
return all_names
3570+
_, defined_names = self._collect_file_scope(
3571+
tree.root_node, language, source,
3572+
)
3573+
result = {n for n in defined_names if not n.startswith("_")}
3574+
with self._lock:
3575+
self._star_export_cache[resolved_path] = result
3576+
return result
3577+
3578+
@staticmethod
3579+
def _extract_dunder_all(root) -> Optional[set[str]]:
3580+
"""Extract names from ``__all__ = [...]``. Returns None if absent."""
3581+
for child in root.children:
3582+
if child.type != "assignment":
3583+
continue
3584+
left = child.children[0] if child.children else None
3585+
if not left or left.type != "identifier" or left.text != b"__all__":
3586+
continue
3587+
for rhs in child.children:
3588+
if rhs.type == "list":
3589+
names: set[str] = set()
3590+
for elem in rhs.children:
3591+
if elem.type == "string":
3592+
for sc in elem.children:
3593+
if sc.type == "string_content":
3594+
val = sc.text.decode(
3595+
"utf-8", errors="replace",
3596+
)
3597+
if val:
3598+
names.add(val)
3599+
return names
3600+
return set()
3601+
return None
3602+
34973603
def _collect_file_scope(
34983604
self, root, language: str, source: bytes,
34993605
) -> tuple[dict[str, str], set[str]]:

0 commit comments

Comments
 (0)