File size: 5,720 Bytes
036a2db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
"""
resolver.py β€” Resolve Python import statements to filesystem paths.
"""

import ast
import logging
import os
from typing import Dict, Optional

from ._warn_once import warn_syntax_error_once, check_and_warn_encoding

logger = logging.getLogger(__name__)


def build_import_map(filename: str, repo_path: str) -> Dict[str, str]:
    """
    Parse imports in a file and resolve them to absolute paths.

    Returns:
        dict: local_name -> absolute_path_of_source_file
        e.g. {"helper": "/repo/utils.py", "Session": "/repo/sessions.py"}

    __init__.py transparency:
        When an import resolves to a package __init__.py, we scan that
        __init__.py for re-export statements (`from .sub import Name`) and
        follow them to the actual definition file. This means:

            from flask import Flask
            # resolves to src/flask/__init__.py
            # __init__.py has: from .app import Flask
            # final result: src/flask/app.py   ← correct
    """
    with open(filename, "rb") as f:
        raw = f.read()
    check_and_warn_encoding(logger, filename, raw)
    source = raw.decode("utf-8", errors="ignore")

    try:
        tree = ast.parse(source)
    except SyntaxError as e:
        warn_syntax_error_once(logger, filename, e)
        return {}

    imports: Dict[str, str] = {}
    file_dir = os.path.dirname(os.path.abspath(filename))
    repo_abs = os.path.abspath(repo_path)

    for node in ast.walk(tree):

        if isinstance(node, ast.ImportFrom):
            module = node.module or ""
            level = node.level

            if level > 0:
                # Relative import: from .utils import helper
                base = file_dir
                for _ in range(level - 1):
                    base = os.path.dirname(base)
                module_path = os.path.join(base, module.replace(".", os.sep))
            else:
                # Absolute import: from requests.utils import helper
                module_path = os.path.join(repo_abs, module.replace(".", os.sep))

            resolved = _resolve_module_path(module_path)

            for alias in node.names:
                local_name = alias.asname or alias.name

                # Check if this is a submodule import (from package import module)
                submodule_path = os.path.join(module_path, alias.name)
                submodule_resolved = _resolve_module_path(submodule_path)

                if submodule_resolved:
                    imports[local_name] = submodule_resolved
                elif resolved:
                    # __init__.py transparency: follow re-exports one level
                    if resolved.endswith("__init__.py"):
                        real = _follow_init_reexport(resolved, alias.name, repo_abs)
                        imports[local_name] = real if real else resolved
                    else:
                        imports[local_name] = resolved

        elif isinstance(node, ast.Import):
            for alias in node.names:
                top_module = alias.name.split(".")[0]
                local_name = alias.asname or top_module
                module_path = os.path.join(repo_abs, alias.name.replace(".", os.sep))
                resolved = _resolve_module_path(module_path)

                if not resolved:
                    # Bare `import x` β€” try the importing file's own directory
                    sibling_path = os.path.join(file_dir, alias.name.replace(".", os.sep))
                    resolved = _resolve_module_path(sibling_path)

                if resolved:
                    imports[local_name] = resolved

    return imports


def _follow_init_reexport(init_path: str, name: str, repo_abs: str) -> Optional[str]:
    """
    Given a package __init__.py and a name imported from it, check whether
    __init__.py re-exports that name from a submodule.

    Example:
        __init__.py contains:  from .app import Flask
        name = "Flask"
        β†’ returns /abs/path/to/app.py

    Only follows one level (no recursive re-export chasing) to stay fast.
    Returns None if the name is not re-exported or the submodule can't be found.
    """
    try:
        with open(init_path, "rb") as f:
            raw = f.read()
        source = raw.decode("utf-8", errors="ignore")
        tree = ast.parse(source)
    except (OSError, SyntaxError):
        return None

    init_dir = os.path.dirname(init_path)

    for node in ast.walk(tree):
        if not isinstance(node, ast.ImportFrom):
            continue
        if node.level == 0:
            continue  # only relative re-exports (from .sub import X)
        for alias in node.names:
            exported_name = alias.asname or alias.name
            if exported_name != name:
                continue
            # Found the re-export β€” resolve the submodule
            module = node.module or ""
            base = init_dir
            for _ in range(node.level - 1):
                base = os.path.dirname(base)
            sub_path = os.path.join(base, module.replace(".", os.sep))
            # Check if alias.name itself is a submodule
            submodule_path = os.path.join(sub_path, alias.name)
            resolved = _resolve_module_path(submodule_path) or _resolve_module_path(sub_path)
            if resolved:
                return resolved

    return None


def _resolve_module_path(module_path: str) -> Optional[str]:
    """Try to find a .py file or package __init__.py for a given path."""
    candidates = [
        module_path + ".py",
        os.path.join(module_path, "__init__.py"),
    ]
    for candidate in candidates:
        if os.path.isfile(candidate):
            return os.path.normpath(candidate)
    return None