|
10 | 10 | import json |
11 | 11 | import logging |
12 | 12 | import re |
| 13 | +import threading |
13 | 14 | from dataclasses import dataclass, field |
14 | 15 | from pathlib import Path |
15 | 16 | from typing import NamedTuple, Optional |
@@ -625,9 +626,11 @@ def __init__(self) -> None: |
625 | 626 | self._parsers: dict[str, object] = {} |
626 | 627 | self._module_file_cache: dict[str, Optional[str]] = {} |
627 | 628 | self._export_symbol_cache: dict[str, Optional[str]] = {} |
| 629 | + self._star_export_cache: dict[str, set[str]] = {} |
628 | 630 | self._tsconfig_resolver = TsconfigResolver() |
629 | 631 | # Per-parse cache of Dart pubspec root lookups; see #87 |
630 | 632 | self._dart_pubspec_cache: dict[tuple[str, str], Optional[Path]] = {} |
| 633 | + self._lock = threading.Lock() |
631 | 634 |
|
632 | 635 | def _get_parser(self, language: str): # type: ignore[arg-type] |
633 | 636 | if language not in self._parsers: |
@@ -708,6 +711,12 @@ def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[E |
708 | 711 | tree.root_node, language, source, |
709 | 712 | ) |
710 | 713 |
|
| 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 | + |
711 | 720 | # Walk the tree |
712 | 721 | self._extract_from_tree( |
713 | 722 | tree.root_node, source, language, file_path_str, nodes, edges, |
@@ -1139,6 +1148,10 @@ def _parse_notebook_cells( |
1139 | 1148 | import_map, defined_names = self._collect_file_scope( |
1140 | 1149 | tree.root_node, lang, concat_bytes, |
1141 | 1150 | ) |
| 1151 | + if lang == "python": |
| 1152 | + self._resolve_star_imports( |
| 1153 | + tree.root_node, file_path_str, lang, import_map, |
| 1154 | + ) |
1142 | 1155 | self._extract_from_tree( |
1143 | 1156 | tree.root_node, concat_bytes, lang, |
1144 | 1157 | file_path_str, all_nodes, all_edges, |
@@ -3494,6 +3507,99 @@ def _extract_solidity_constructs( |
3494 | 3507 |
|
3495 | 3508 | return False |
3496 | 3509 |
|
| 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 | + |
3497 | 3603 | def _collect_file_scope( |
3498 | 3604 | self, root, language: str, source: bytes, |
3499 | 3605 | ) -> tuple[dict[str, str], set[str]]: |
|
0 commit comments