coretrace-python-analyzer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. coretrace_python/__init__.py +4 -0
  2. coretrace_python/__main__.py +4 -0
  3. coretrace_python/abstract/__init__.py +45 -0
  4. coretrace_python/abstract/constants.py +226 -0
  5. coretrace_python/abstract/heap.py +252 -0
  6. coretrace_python/abstract/ranges.py +285 -0
  7. coretrace_python/abstract/values.py +56 -0
  8. coretrace_python/analysis/__init__.py +31 -0
  9. coretrace_python/analysis/manager.py +165 -0
  10. coretrace_python/analysis/provider.py +73 -0
  11. coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py +46 -0
  12. coretrace_python/bundled/dependency/dependency_policy/plugin.toml +9 -0
  13. coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml +9 -0
  14. coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +56 -0
  15. coretrace_python/bundled/dependency/sample_advisories/plugin.toml +9 -0
  16. coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py +109 -0
  17. coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml +9 -0
  18. coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +43 -0
  19. coretrace_python/bundled/models/cli/cli_models.py +35 -0
  20. coretrace_python/bundled/models/cli/plugin.toml +9 -0
  21. coretrace_python/bundled/models/credentials/credential_models.py +43 -0
  22. coretrace_python/bundled/models/credentials/plugin.toml +9 -0
  23. coretrace_python/bundled/models/django/django_models.py +123 -0
  24. coretrace_python/bundled/models/django/plugin.toml +9 -0
  25. coretrace_python/bundled/models/fastapi/fastapi_models.py +29 -0
  26. coretrace_python/bundled/models/fastapi/plugin.toml +9 -0
  27. coretrace_python/bundled/models/flask/flask_models.py +53 -0
  28. coretrace_python/bundled/models/flask/plugin.toml +9 -0
  29. coretrace_python/bundled/models/http_clients/http_client_models.py +43 -0
  30. coretrace_python/bundled/models/http_clients/plugin.toml +9 -0
  31. coretrace_python/bundled/models/python_stdlib/plugin.toml +9 -0
  32. coretrace_python/bundled/models/python_stdlib/python_stdlib.py +68 -0
  33. coretrace_python/bundled/models/sqlalchemy/plugin.toml +9 -0
  34. coretrace_python/bundled/models/sqlalchemy/sqlalchemy_models.py +47 -0
  35. coretrace_python/bundled/secrets/config_secrets/config_secrets.py +38 -0
  36. coretrace_python/bundled/secrets/config_secrets/plugin.toml +9 -0
  37. coretrace_python/bundled/secrets/hardcoded_secrets/hardcoded_secrets.py +19 -0
  38. coretrace_python/bundled/secrets/hardcoded_secrets/plugin.toml +9 -0
  39. coretrace_python/bundled/security/command_injection/command_injection.py +17 -0
  40. coretrace_python/bundled/security/command_injection/plugin.toml +9 -0
  41. coretrace_python/bundled/security/insecure_deserialization/insecure_deserialization.py +17 -0
  42. coretrace_python/bundled/security/insecure_deserialization/plugin.toml +9 -0
  43. coretrace_python/bundled/security/open_redirect/open_redirect.py +17 -0
  44. coretrace_python/bundled/security/open_redirect/plugin.toml +9 -0
  45. coretrace_python/bundled/security/path_traversal/path_traversal.py +17 -0
  46. coretrace_python/bundled/security/path_traversal/plugin.toml +9 -0
  47. coretrace_python/bundled/security/plaintext_credentials/plaintext_credentials.py +21 -0
  48. coretrace_python/bundled/security/plaintext_credentials/plugin.toml +9 -0
  49. coretrace_python/bundled/security/sql_injection/plugin.toml +9 -0
  50. coretrace_python/bundled/security/sql_injection/sql_injection.py +17 -0
  51. coretrace_python/bundled/security/ssrf/plugin.toml +9 -0
  52. coretrace_python/bundled/security/ssrf/ssrf.py +17 -0
  53. coretrace_python/bundled/security/xss/plugin.toml +9 -0
  54. coretrace_python/bundled/security/xss/xss.py +17 -0
  55. coretrace_python/bundled/syntax/dangerous_eval/dangerous_eval.py +19 -0
  56. coretrace_python/bundled/syntax/dangerous_eval/plugin.toml +9 -0
  57. coretrace_python/bundled/syntax/flask_debug/flask_debug.py +63 -0
  58. coretrace_python/bundled/syntax/flask_debug/plugin.toml +9 -0
  59. coretrace_python/bundled/syntax/missing_timeout/missing_timeout.py +51 -0
  60. coretrace_python/bundled/syntax/missing_timeout/plugin.toml +9 -0
  61. coretrace_python/bundled/syntax/weak_crypto/plugin.toml +9 -0
  62. coretrace_python/bundled/syntax/weak_crypto/weak_crypto.py +19 -0
  63. coretrace_python/cache.py +310 -0
  64. coretrace_python/cfg/__init__.py +46 -0
  65. coretrace_python/cfg/builder.py +589 -0
  66. coretrace_python/cfg/dominance.py +183 -0
  67. coretrace_python/cfg/model.py +166 -0
  68. coretrace_python/cli.py +216 -0
  69. coretrace_python/dataflow/__init__.py +29 -0
  70. coretrace_python/dataflow/lattice.py +78 -0
  71. coretrace_python/dataflow/solver.py +96 -0
  72. coretrace_python/dependency/__init__.py +44 -0
  73. coretrace_python/dependency/advisories.py +168 -0
  74. coretrace_python/dependency/correlation.py +87 -0
  75. coretrace_python/dependency/graph.py +274 -0
  76. coretrace_python/dependency/policy.py +65 -0
  77. coretrace_python/dependency/sbom.py +65 -0
  78. coretrace_python/engine.py +688 -0
  79. coretrace_python/findings/__init__.py +13 -0
  80. coretrace_python/findings/coverage.py +45 -0
  81. coretrace_python/findings/model.py +49 -0
  82. coretrace_python/findings/refutation.py +427 -0
  83. coretrace_python/frontend/__init__.py +18 -0
  84. coretrace_python/frontend/ast_adapter.py +447 -0
  85. coretrace_python/frontend/parser.py +23 -0
  86. coretrace_python/hir/__init__.py +5 -0
  87. coretrace_python/hir/nodes.py +584 -0
  88. coretrace_python/hir/visitors.py +36 -0
  89. coretrace_python/interprocedural/__init__.py +49 -0
  90. coretrace_python/interprocedural/callgraph.py +291 -0
  91. coretrace_python/interprocedural/modulegraph.py +218 -0
  92. coretrace_python/interprocedural/summaries.py +463 -0
  93. coretrace_python/ir/__init__.py +5 -0
  94. coretrace_python/ir/defuse.py +74 -0
  95. coretrace_python/ir/lowering.py +575 -0
  96. coretrace_python/ir/model.py +481 -0
  97. coretrace_python/ir/printer.py +201 -0
  98. coretrace_python/ir/ssa.py +277 -0
  99. coretrace_python/plugins/__init__.py +58 -0
  100. coretrace_python/plugins/api.py +153 -0
  101. coretrace_python/plugins/detectors.py +114 -0
  102. coretrace_python/plugins/loader.py +84 -0
  103. coretrace_python/plugins/manifest.py +112 -0
  104. coretrace_python/plugins/registry.py +34 -0
  105. coretrace_python/plugins/secrets.py +330 -0
  106. coretrace_python/reporters/__init__.py +22 -0
  107. coretrace_python/reporters/json_format.py +48 -0
  108. coretrace_python/reporters/report.py +23 -0
  109. coretrace_python/reporters/sarif.py +70 -0
  110. coretrace_python/reporters/text.py +24 -0
  111. coretrace_python/semantic/__init__.py +9 -0
  112. coretrace_python/semantic/identity.py +39 -0
  113. coretrace_python/semantic/imports.py +131 -0
  114. coretrace_python/semantic/scopes.py +473 -0
  115. coretrace_python/semantic/symbols.py +87 -0
  116. coretrace_python/source/__init__.py +13 -0
  117. coretrace_python/source/manager.py +81 -0
  118. coretrace_python/source/model.py +57 -0
  119. coretrace_python/taint/__init__.py +55 -0
  120. coretrace_python/taint/engine.py +800 -0
  121. coretrace_python/taint/models.py +317 -0
  122. coretrace_python/taint/routes.py +99 -0
  123. coretrace_python_analyzer-0.1.0.dist-info/METADATA +74 -0
  124. coretrace_python_analyzer-0.1.0.dist-info/RECORD +126 -0
  125. coretrace_python_analyzer-0.1.0.dist-info/WHEEL +4 -0
  126. coretrace_python_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,291 @@
1
+ """Intra-module call graph (architecture §20).
2
+
3
+ Every call site of every analysable function resolves, through the SSA form, to a
4
+ ``KnownFunction`` defined at module level, an ``ExternalSymbol`` reached through imports
5
+ or builtins, or ``UnknownTarget`` (parameters, attributes, methods) until type inference
6
+ and framework models narrow it down.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from types import MappingProxyType
14
+ from typing import ClassVar
15
+
16
+ from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis
17
+ from coretrace_python.cfg import CFGError
18
+ from coretrace_python.hir import nodes
19
+ from coretrace_python.ir.lowering import LoweringError, analyzable_functions, qualified_name
20
+ from coretrace_python.ir.model import (
21
+ Call,
22
+ FunctionIR,
23
+ GetAttr,
24
+ GetItem,
25
+ Global,
26
+ MakeFunction,
27
+ Symbol,
28
+ Value,
29
+ WithEnter,
30
+ )
31
+ from coretrace_python.ir.ssa import SSAAnalysis
32
+ from coretrace_python.semantic.scopes import BindingKind, ScopeAnalysis, ScopeTable
33
+ from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId, SymbolTable
34
+ from coretrace_python.source import SourceSpan
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class KnownFunction:
39
+ name: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ExternalSymbol:
44
+ symbol: SymbolId
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class UnknownTarget:
49
+ pass
50
+
51
+
52
+ Target = KnownFunction | ExternalSymbol | UnknownTarget
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class CallSite:
57
+ caller: str
58
+ location: SourceSpan
59
+ target: Target
60
+ arguments: int
61
+ keywords: int
62
+
63
+
64
+ class CallGraph:
65
+ def __init__(
66
+ self,
67
+ definitions: Mapping[str, nodes.Function],
68
+ sites: Mapping[str, tuple[CallSite, ...]],
69
+ unsupported: frozenset[str],
70
+ symbols: Mapping[str, Mapping[Value, SymbolId]] | None = None,
71
+ ) -> None:
72
+ self._symbols = {name: MappingProxyType(dict(found)) for name, found in (symbols or {}).items()}
73
+ self.definitions: Mapping[str, nodes.Function] = MappingProxyType(dict(definitions))
74
+ # A graph rebuilt from cached call sites has sites but no definitions.
75
+ self.functions = tuple(dict.fromkeys((*definitions, *sites)))
76
+ self._names = {function.span: name for name, function in definitions.items()}
77
+ self.unsupported = unsupported
78
+ self._sites = MappingProxyType(dict(sites))
79
+ self._targets = {
80
+ (site.caller, site.location): site.target for found in sites.values() for site in found
81
+ }
82
+ callers: dict[str, set[str]] = {name: set() for name in definitions}
83
+ for found in sites.values():
84
+ for site in found:
85
+ if isinstance(site.target, KnownFunction):
86
+ callers.setdefault(site.target.name, set()).add(site.caller)
87
+ self._callers = {name: frozenset(found) for name, found in callers.items()}
88
+
89
+ def name_of(self, function: nodes.Function) -> str:
90
+ return self._names[function.span]
91
+
92
+ def symbols(self, name: str) -> Mapping[Value, SymbolId]:
93
+ """Values of ``name`` that denote a symbol, including derived call-chain symbols."""
94
+
95
+ return self._symbols.get(name, MappingProxyType({}))
96
+
97
+ def sites(self, caller: str) -> tuple[CallSite, ...]:
98
+ return self._sites.get(caller, ())
99
+
100
+ def target_at(self, caller: str, location: SourceSpan) -> Target:
101
+ return self._targets.get((caller, location), UnknownTarget())
102
+
103
+ def callees(self, caller: str) -> frozenset[str]:
104
+ return frozenset(
105
+ site.target.name for site in self.sites(caller) if isinstance(site.target, KnownFunction)
106
+ )
107
+
108
+ def callers(self, name: str) -> frozenset[str]:
109
+ return self._callers.get(name, frozenset())
110
+
111
+
112
+ def derive_symbols(
113
+ function: FunctionIR, initial: Mapping[Value, SymbolId] | None = None
114
+ ) -> dict[Value, SymbolId]:
115
+ """Symbols of values: ``Symbol`` results, attributes and items of symbol values,
116
+ results of calling a symbol (``sqlite3.connect(p)`` denotes ``python.sqlite3.connect``)
117
+ and the values a ``with`` on such a result binds. Known functions derive nothing;
118
+ parameters only through ``initial``, their annotated classes."""
119
+
120
+ symbols: dict[Value, SymbolId] = dict(initial or {})
121
+ changed = True
122
+ while changed:
123
+ changed = False
124
+ for block in function.blocks:
125
+ for instruction in block.instructions:
126
+ if instruction.result is None or instruction.result in symbols:
127
+ continue
128
+ symbol: SymbolId | None = None
129
+ if isinstance(instruction, Symbol):
130
+ symbol = instruction.symbol_id
131
+ elif isinstance(instruction, GetAttr) and instruction.object in symbols:
132
+ symbol = symbols[instruction.object].attribute(instruction.attribute)
133
+ elif isinstance(instruction, GetItem) and instruction.object in symbols:
134
+ # An item of a symbol-denoted container (``request.files['f']``)
135
+ # carries the container's symbol, so its methods resolve.
136
+ symbol = symbols[instruction.object]
137
+ elif isinstance(instruction, Call | WithEnter):
138
+ origin = (
139
+ instruction.callee if isinstance(instruction, Call) else instruction.context
140
+ )
141
+ symbol = symbols.get(origin)
142
+ if symbol is not None:
143
+ symbols[instruction.result] = symbol
144
+ changed = True
145
+ return symbols
146
+
147
+
148
+ def resolve_targets(
149
+ function: FunctionIR,
150
+ scopes: ScopeTable,
151
+ known: frozenset[str],
152
+ parameters: Mapping[Value, SymbolId] | None = None,
153
+ nested: frozenset[str] = frozenset(),
154
+ classes: Mapping[str, frozenset[str]] | None = None,
155
+ owner: str | None = None,
156
+ typed: Mapping[Value, str] | None = None,
157
+ ) -> tuple[dict[Value, Target], dict[Value, SymbolId]]:
158
+ """Map every callee value of ``function`` to its target, and every symbol value.
159
+ ``nested`` names the functions defined inside this one (``outer.inner``);
160
+ ``classes`` maps the module's classes to their methods, ``owner`` is the class of a
161
+ method and ``typed`` the parameters annotated with a module class. ``App(x)`` is a
162
+ call to ``App.__init__``, ``app.run()`` and ``self.run()`` calls to ``App.run``."""
163
+
164
+ module = scopes.module_scope
165
+ symbols = derive_symbols(function, parameters)
166
+ targets: dict[Value, Target] = {
167
+ value: ExternalSymbol(symbol) for value, symbol in symbols.items()
168
+ }
169
+ classes = classes or {}
170
+ instance_of: dict[Value, str] = dict(typed or {})
171
+ if owner is not None and function.parameters:
172
+ instance_of[function.parameters[0]] = owner
173
+ defs = {i.result: i for block in function.blocks for i in block.instructions if i.result}
174
+ for block in function.blocks:
175
+ for instruction in block.instructions:
176
+ if isinstance(instruction, MakeFunction):
177
+ qualified = f"{function.name}.{instruction.name}"
178
+ if qualified in nested:
179
+ targets[instruction.result] = KnownFunction(qualified)
180
+ elif isinstance(instruction, Global):
181
+ binding = module.bindings.get(instruction.name)
182
+ if binding is None:
183
+ continue
184
+ if binding.kind is BindingKind.FUNCTION and instruction.name in known:
185
+ targets[instruction.result] = KnownFunction(instruction.name)
186
+ elif binding.kind is BindingKind.CLASS and "__init__" in classes.get(instruction.name, ()):
187
+ targets[instruction.result] = KnownFunction(f"{instruction.name}.__init__")
188
+ elif isinstance(instruction, Call):
189
+ callee = defs.get(instruction.callee)
190
+ if isinstance(callee, Global) and callee.name in classes:
191
+ instance_of[instruction.result] = callee.name
192
+ elif isinstance(instruction, GetAttr) and instruction.object in instance_of:
193
+ method = f"{instance_of[instruction.object]}.{instruction.attribute}"
194
+ if method in nested:
195
+ targets[instruction.result] = KnownFunction(method)
196
+ return targets, symbols
197
+
198
+
199
+ def _annotated(
200
+ function: nodes.Function, ssa: FunctionIR, scopes: ScopeTable, table: SymbolTable
201
+ ) -> dict[Value, SymbolId]:
202
+ """Parameters annotated with a resolvable class denote that class (``db: Session``)."""
203
+
204
+ scope = scopes.scope_for(function)
205
+ enclosing = scope.parent if scope.parent is not None else scope.id
206
+ found: dict[Value, SymbolId] = {}
207
+ # Captured variables follow the explicit parameters and carry no annotation.
208
+ for value, parameter in zip(ssa.parameters, function.parameters, strict=False):
209
+ if parameter.annotation is None:
210
+ continue
211
+ symbol = table.resolve_expression(enclosing, parameter.annotation)
212
+ if symbol is not None:
213
+ found[value] = symbol
214
+ return found
215
+
216
+
217
+ def _typed_with_module_classes(
218
+ function: nodes.Function, ssa: FunctionIR, classes: Mapping[str, frozenset[str]]
219
+ ) -> dict[Value, str]:
220
+ """Parameters annotated with a class of this module (``a: App``)."""
221
+
222
+ found: dict[Value, str] = {}
223
+ for value, parameter in zip(ssa.parameters, function.parameters, strict=False):
224
+ annotation = parameter.annotation
225
+ if isinstance(annotation, nodes.Name) and annotation.identifier in classes:
226
+ found[value] = annotation.identifier
227
+ return found
228
+
229
+
230
+ class CallGraphAnalysis(Analysis[CallGraph]):
231
+ name: ClassVar[str] = "interprocedural.callgraph"
232
+ requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({SSAAnalysis, ScopeAnalysis, SymbolAnalysis})
233
+
234
+ @classmethod
235
+ def compute(cls, ctx: AnalysisContext) -> CallGraph:
236
+ scopes = ctx.get(ScopeAnalysis)
237
+ table = ctx.get(SymbolAnalysis)
238
+ definitions: dict[str, nodes.Function] = {}
239
+ for function in analyzable_functions(ctx.module):
240
+ # A property and its setter, or a redefinition, share a qualified name; each
241
+ # definition still needs a name of its own.
242
+ base = unique = qualified_name(scopes, function)
243
+ ordinal = 2
244
+ while unique in definitions:
245
+ unique = f"{base}__{ordinal}"
246
+ ordinal += 1
247
+ definitions[unique] = function
248
+ known = frozenset(
249
+ name for name, function in definitions.items() if function in ctx.module.body
250
+ )
251
+ classes = {
252
+ statement.name: frozenset(m.name for m in statement.body if isinstance(m, nodes.Function))
253
+ for statement in ctx.module.body
254
+ if isinstance(statement, nodes.Class)
255
+ }
256
+ sites: dict[str, tuple[CallSite, ...]] = {}
257
+ symbols: dict[str, Mapping[Value, SymbolId]] = {}
258
+ unsupported: set[str] = set()
259
+ for name, function in definitions.items():
260
+ try:
261
+ ssa = ctx.get(SSAAnalysis, function)
262
+ except (LoweringError, CFGError):
263
+ unsupported.add(name)
264
+ sites[name] = ()
265
+ continue
266
+ owner = name.rsplit(".", 1)[0] if "." in name and name.rsplit(".", 1)[0] in classes else None
267
+ targets, symbols[name] = resolve_targets(
268
+ ssa,
269
+ scopes,
270
+ known,
271
+ _annotated(function, ssa, scopes, table),
272
+ frozenset(definitions),
273
+ classes,
274
+ owner,
275
+ _typed_with_module_classes(function, ssa, classes),
276
+ )
277
+ found: list[CallSite] = []
278
+ for block in ssa.blocks:
279
+ for instruction in block.instructions:
280
+ if isinstance(instruction, Call):
281
+ found.append(
282
+ CallSite(
283
+ name,
284
+ instruction.location,
285
+ targets.get(instruction.callee, UnknownTarget()),
286
+ len(instruction.arguments),
287
+ len(instruction.keywords),
288
+ )
289
+ )
290
+ sites[name] = tuple(found)
291
+ return CallGraph(definitions, sites, frozenset(unsupported), symbols)
@@ -0,0 +1,218 @@
1
+ """Module graph and project-wide summary index (architecture §21).
2
+
3
+ A project is a directory of Python files. Each file is one module named after its
4
+ packages (``app/helpers.py`` is ``app.helpers``); the graph records which project modules
5
+ each module imports. Functions defined in the project get project symbols
6
+ (``python.app.helpers.run``) whose summaries live in a ``SummaryIndex`` that the engine
7
+ provides to every module's manager, so calls into other files are analysed through
8
+ summaries rather than by retaining every module's PyIR.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Iterable, Mapping
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from types import MappingProxyType
17
+
18
+ from coretrace_python.hir import nodes
19
+ from coretrace_python.interprocedural.summaries import ProjectSummaries, SummaryIndex
20
+ from coretrace_python.semantic.imports import ImportTable
21
+ from coretrace_python.semantic.symbols import SymbolId
22
+ from coretrace_python.source import SourceFile, SourceManager
23
+
24
+ IGNORED_DIRECTORIES = frozenset({"__pycache__", "node_modules", "venv", "build", "dist", "site-packages"})
25
+
26
+
27
+ def discover_files(root: Path, pattern: str = "*") -> tuple[Path, ...]:
28
+ """Every file matching ``pattern`` under ``root``, skipping hidden and tooling
29
+ directories and virtual environments, recognised by the ``pyvenv.cfg`` at their root
30
+ whatever their name."""
31
+
32
+ found: list[Path] = []
33
+ environments: dict[Path, bool] = {}
34
+ for path in sorted(root.rglob(pattern)):
35
+ if not path.is_file():
36
+ continue
37
+ relative = path.relative_to(root)
38
+ if any(p.startswith(".") or p in IGNORED_DIRECTORIES for p in relative.parts[:-1]):
39
+ continue
40
+ if any(_is_environment(root / Path(*relative.parts[:depth]), environments) for depth in range(1, len(relative.parts))):
41
+ continue
42
+ found.append(path)
43
+ return tuple(found)
44
+
45
+
46
+ def discover_sources(
47
+ root: Path, manager: SourceManager, errors: list[tuple[Path, str]] | None = None
48
+ ) -> tuple[SourceFile, ...]:
49
+ """Load every ``.py`` file under ``root`` (see ``discover_files``). A file that cannot
50
+ be read or decoded is skipped and reported in ``errors``: Python could not import it
51
+ either."""
52
+
53
+ found: list[SourceFile] = []
54
+ for path in discover_files(root, "*.py"):
55
+ try:
56
+ found.append(manager.load_file(path))
57
+ except (OSError, UnicodeDecodeError) as error:
58
+ if errors is None:
59
+ raise
60
+ errors.append((path, str(error)))
61
+ return tuple(found)
62
+
63
+
64
+ def _is_environment(directory: Path, known: dict[Path, bool]) -> bool:
65
+ if directory not in known:
66
+ known[directory] = (directory / "pyvenv.cfg").is_file()
67
+ return known[directory]
68
+
69
+
70
+ def project_symbol(module_name: str, qualified_name: str) -> SymbolId:
71
+ """The canonical symbol of a project function. A module whose name is not an
72
+ identifier cannot be imported by that name, but its functions still need stable
73
+ symbols: invalid characters become underscores."""
74
+
75
+ module = ".".join(_component(part) for part in module_name.split("."))
76
+ return SymbolId(f"python.{module}.{qualified_name}")
77
+
78
+
79
+ def _component(part: str) -> str:
80
+ if part.isidentifier():
81
+ return part
82
+ cleaned = "".join(c if (c.isalnum() or c == "_") else "_" for c in part)
83
+ return cleaned if cleaned.isidentifier() else f"_{cleaned}"
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class ModuleGraph:
88
+ _sources: Mapping[str, SourceFile]
89
+ _imports: Mapping[str, frozenset[str]]
90
+
91
+ @property
92
+ def modules(self) -> tuple[str, ...]:
93
+ return tuple(sorted(self._sources))
94
+
95
+ def source(self, name: str) -> SourceFile:
96
+ return self._sources[name]
97
+
98
+ def imports(self, name: str) -> frozenset[str]:
99
+ return self._imports.get(name, frozenset())
100
+
101
+ def importers(self, name: str) -> frozenset[str]:
102
+ return frozenset(m for m, imported in self._imports.items() if name in imported)
103
+
104
+ def schedule(self) -> tuple[tuple[frozenset[str], ...], ...]:
105
+ """Strongly connected components in waves: every component of a wave imports,
106
+ outside itself, only components of earlier waves, so one wave can be analysed
107
+ in parallel and a component's imports are final when it starts (§29)."""
108
+
109
+ names = frozenset(self._sources)
110
+ edges = {name: sorted(m for m in self.imports(name) if m in names) for name in names}
111
+ component_of: dict[str, int] = {}
112
+ components: list[frozenset[str]] = []
113
+ index: dict[str, int] = {}
114
+ low: dict[str, int] = {}
115
+ stack: list[str] = []
116
+
117
+ def visit(name: str) -> None:
118
+ index[name] = low[name] = len(index)
119
+ stack.append(name)
120
+ for imported in edges[name]:
121
+ if imported not in index:
122
+ visit(imported)
123
+ low[name] = min(low[name], low[imported])
124
+ elif imported in stack:
125
+ low[name] = min(low[name], index[imported])
126
+ if low[name] == index[name]:
127
+ members: list[str] = []
128
+ while True:
129
+ member = stack.pop()
130
+ members.append(member)
131
+ if member == name:
132
+ break
133
+ for member in members:
134
+ component_of[member] = len(components)
135
+ components.append(frozenset(members))
136
+
137
+ for name in sorted(names):
138
+ if name not in index:
139
+ visit(name)
140
+
141
+ depth: dict[int, int] = {}
142
+
143
+ def level(component: int) -> int:
144
+ if component not in depth:
145
+ below = {
146
+ component_of[imported]
147
+ for member in components[component]
148
+ for imported in edges[member]
149
+ if component_of[imported] != component
150
+ }
151
+ depth[component] = 1 + max((level(c) for c in below), default=-1)
152
+ return depth[component]
153
+
154
+ waves: dict[int, list[frozenset[str]]] = {}
155
+ for number in range(len(components)):
156
+ waves.setdefault(level(number), []).append(components[number])
157
+ return tuple(
158
+ tuple(sorted(waves[wave], key=min)) for wave in sorted(waves)
159
+ )
160
+
161
+
162
+ def build_module_graph(
163
+ sources: Mapping[str, SourceFile],
164
+ modules: Mapping[str, nodes.Module],
165
+ imports: Mapping[str, ImportTable],
166
+ ) -> ModuleGraph:
167
+ """Edges from each module to the project modules it imports, in any scope."""
168
+
169
+ names = frozenset(sources)
170
+ edges: dict[str, frozenset[str]] = {}
171
+ for name, module in modules.items():
172
+ candidates: set[str] = set()
173
+ dotted_tops: set[str] = set()
174
+ for statement in _statements(module.body):
175
+ if isinstance(statement, nodes.Import):
176
+ for alias in statement.names:
177
+ candidates.add(alias.name)
178
+ if "." in alias.name:
179
+ dotted_tops.add(alias.name.partition(".")[0])
180
+ for symbol in imports[name].all_symbols():
181
+ path = symbol.canonical_name.removeprefix("python.")
182
+ # ``import app.config`` binds ``app``; the statement already named the module.
183
+ if path not in dotted_tops:
184
+ candidates.add(path)
185
+ # The most specific project module each import names; ``app.helpers`` implies
186
+ # the ``app`` package, which is not an edge worth recording.
187
+ found = {
188
+ next((m for m in _prefixes(candidate) if m in names), None) for candidate in candidates
189
+ }
190
+ edges[name] = frozenset(m for m in found if m is not None and m != name)
191
+ return ModuleGraph(MappingProxyType(dict(sources)), MappingProxyType(edges))
192
+
193
+
194
+ def _prefixes(dotted: str) -> list[str]:
195
+ parts = dotted.split(".")
196
+ return [".".join(parts[:length]) for length in range(len(parts), 0, -1)]
197
+
198
+
199
+ def _statements(body: Iterable[nodes.Statement]) -> Iterable[nodes.Statement]:
200
+ for statement in body:
201
+ yield statement
202
+ for attribute in ("body", "orelse", "finalbody"):
203
+ nested = getattr(statement, attribute, None)
204
+ if isinstance(nested, tuple):
205
+ yield from _statements(nested)
206
+ if isinstance(statement, nodes.Try):
207
+ for handler in statement.handlers:
208
+ yield from _statements(handler.body)
209
+
210
+ __all__ = [
211
+ "IGNORED_DIRECTORIES",
212
+ "ModuleGraph",
213
+ "ProjectSummaries",
214
+ "SummaryIndex",
215
+ "build_module_graph",
216
+ "discover_sources",
217
+ "project_symbol",
218
+ ]