coretrace-python-analyzer 0.18.0__py3-none-any.whl → 0.19.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.
@@ -1,4 +1,4 @@
1
1
  """CoreTrace's Python static analysis frontend."""
2
2
 
3
- __version__ = "0.18.0"
3
+ __version__ = "0.19.0"
4
4
 
coretrace_python/cache.py CHANGED
@@ -42,7 +42,7 @@ from coretrace_python.interprocedural import (
42
42
  from coretrace_python.semantic.symbols import SymbolId
43
43
  from coretrace_python.source import SourceId, SourceSpan
44
44
 
45
- CACHE_FORMAT = 10
45
+ CACHE_FORMAT = 11
46
46
 
47
47
 
48
48
  @dataclass(frozen=True)
@@ -134,7 +134,7 @@ class ProjectCache:
134
134
  def encode(module: CachedModule) -> dict[str, Any]:
135
135
  return {
136
136
  "format": CACHE_FORMAT,
137
- "functions": [[f.name, _encode_span(f.span), f.entry_point] for f in module.functions],
137
+ "functions": [[f.name, _encode_span(f.span), f.entry_point, list(f.aliases)] for f in module.functions],
138
138
  "summaries": {name: _encode_summary(s) for name, s in module.summaries.items()},
139
139
  "sites": [_encode_site(site) for site in module.sites],
140
140
  "findings": [_encode_finding(finding) for finding in module.findings],
@@ -196,8 +196,13 @@ def _decode_span(data: Any) -> SourceSpan:
196
196
 
197
197
 
198
198
  def _decode_function(data: Any) -> ModuleFunction:
199
- name, span, entry_point = data
200
- return ModuleFunction(_string(name), _decode_span(span), None if entry_point is None else _string(entry_point))
199
+ name, span, entry_point, aliases = data
200
+ return ModuleFunction(
201
+ _string(name),
202
+ _decode_span(span),
203
+ None if entry_point is None else _string(entry_point),
204
+ tuple(_string(alias) for alias in aliases),
205
+ )
201
206
 
202
207
 
203
208
  def _decode_read(data: Any) -> SymbolRead:
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import concurrent.futures
10
10
  import hashlib
11
11
  import multiprocessing
12
+ from collections import Counter
12
13
  from collections.abc import Callable, Iterable, Mapping, Sequence
13
14
  from dataclasses import dataclass, field, replace
14
15
  from pathlib import Path
@@ -88,6 +89,7 @@ from coretrace_python.interprocedural import (
88
89
  TemplateCallsAnalysis,
89
90
  build_module_graph,
90
91
  discover_sources,
92
+ name_modules,
91
93
  project_symbol,
92
94
  )
93
95
  from coretrace_python.ir import PYIR_SCHEMA_VERSION
@@ -371,7 +373,11 @@ def analyze_project(
371
373
  strongly connected components of the module graph, imports first; with ``jobs``
372
374
  above one the components of a wave are analysed in that many processes (§29).
373
375
  ``advisory_files`` add to the ``advisories.json`` at ``root``; ``policy_file``
374
- replaces the ``coretrace-policy.toml`` there."""
376
+ replaces the ``coretrace-policy.toml`` there.
377
+
378
+ Every module is known by the name ``name_modules`` gives its file: its import name,
379
+ or its path from the root when another file has that import name. Such files carry
380
+ an ``ambiguous-module`` note and are covered as ``ambiguous``."""
375
381
 
376
382
  if jobs < 1:
377
383
  raise ValueError("jobs must be at least 1")
@@ -398,18 +404,18 @@ def analyze_project(
398
404
  files: dict[str, SourceFile] = {}
399
405
  unreadable: list[tuple[Path, str]] = []
400
406
  coverage: list[FileCoverage] = []
401
- discovered = discover_sources(root, sources, unreadable)
407
+ named = name_modules(root, discover_sources(root, sources, unreadable))
402
408
  for path, reason in unreadable:
403
409
  findings.append(_note("syntax-error", f"{path}: {reason}", sources.add_source(str(path), ""), 1))
404
410
  coverage.append(FileCoverage(str(path), "unreadable", 0, 0))
405
- for source in discovered:
411
+ for name, source in named.items():
406
412
  try:
407
- modules[source.module_name] = build_hir(source)
413
+ modules[name] = build_hir(source)
408
414
  except (ParseError, HIRBuildError) as error:
409
415
  findings.append(_note("syntax-error", str(error), source, 1))
410
416
  coverage.append(FileCoverage(str(source.source_id), "syntax-error", 0, 0))
411
417
  continue
412
- files[source.module_name] = source
418
+ files[name] = source
413
419
 
414
420
  managers = {name: _register_all(module) for name, module in modules.items()}
415
421
  probe = next(iter(managers.values()), None) or _register_all(build_hir(sources.add_source("<empty>", "")))
@@ -439,6 +445,8 @@ def analyze_project(
439
445
  graph = build_module_graph(
440
446
  {name: files[name] for name in analysable}, {name: modules[name] for name in analysable}, imports
441
447
  )
448
+ shared = _shared_names(graph)
449
+ findings.extend(_ambiguity_notes(graph, shared, root))
442
450
  routes: dict[SymbolId, EntryPoint] = {}
443
451
  for name in sorted(analysable):
444
452
  for symbol, registered in _routes_of(analysable[name]).items():
@@ -447,7 +455,8 @@ def analyze_project(
447
455
  templates = project_templates(root)
448
456
  clearing = models.clearing(escaped)
449
457
  template_calls = models.template_calls(
450
- templates.filters, request_processor(modules[name] for name in sorted(analysable))
458
+ templates.filters,
459
+ request_processor((modules[name], analysable[name].get(ScopeAnalysis)) for name in sorted(analysable)),
451
460
  )
452
461
  for manager in analysable.values():
453
462
  manager.provide(RegisteredRoutes, routes)
@@ -514,15 +523,16 @@ def analyze_project(
514
523
  if pool is not None:
515
524
  pool.shutdown()
516
525
 
517
- index = _seed(results, graph, frozenset())
526
+ index = _index(results, {name: name for name in results})
518
527
  call_graphs: dict[str, CallGraph] = {}
519
528
  functions: dict[str, tuple[ModuleFunction, ...]] = {}
520
529
  for name in sorted(analysable):
521
530
  entry = results[name]
522
531
  findings.extend(entry.findings)
523
532
  unsupported = sum(1 for f in entry.findings if f.rule_id == "unsupported-syntax")
533
+ status = "ambiguous" if files[name].module_name in shared else "analysed"
524
534
  coverage.append(
525
- FileCoverage(str(files[name].source_id), "analysed", len(entry.functions), len(entry.functions) - unsupported)
535
+ FileCoverage(str(files[name].source_id), status, len(entry.functions), len(entry.functions) - unsupported)
526
536
  )
527
537
  functions[name] = entry.functions
528
538
  sites: dict[str, list[CallSite]] = {function.name: [] for function in entry.functions}
@@ -644,24 +654,76 @@ def _advisory_paths(root: Path, advisory_files: Sequence[Path]) -> tuple[Path, .
644
654
  return (*([default] if default.is_file() else []), *advisory_files)
645
655
 
646
656
 
657
+ def _shared_names(graph: ModuleGraph) -> dict[str, list[str]]:
658
+ """The import names several analysable files have, each with those files' modules."""
659
+
660
+ by_name: dict[str, list[str]] = {}
661
+ for name in graph.modules:
662
+ by_name.setdefault(graph.source(name).module_name, []).append(name)
663
+ return {import_name: names for import_name, names in by_name.items() if len(names) > 1}
664
+
665
+
666
+ def _ambiguity_notes(graph: ModuleGraph, shared: Mapping[str, list[str]], root: Path) -> list[Finding]:
667
+ """One note on each file whose import name another file shares, since a symbol of
668
+ that name may denote a function of either, and one on each importer whose import of
669
+ such a name no root resolved, since calls through it are not followed."""
670
+
671
+ def located(name: str) -> str:
672
+ return _located(_path_of(graph.source(name)), root)
673
+
674
+ notes: list[Finding] = []
675
+ for import_name, names in sorted(shared.items()):
676
+ for name in names:
677
+ others = ", ".join(located(other) for other in names if other != name)
678
+ message = (
679
+ f"{located(name)} is the module '{import_name}' like {others}: a symbol "
680
+ f"python.{import_name}.* may name a function of either, and an import of "
681
+ f"'{import_name}' resolves only from its own root"
682
+ )
683
+ notes.append(_note("ambiguous-module", message, graph.source(name), 1))
684
+ for name in graph.modules:
685
+ for import_name in sorted(graph.unresolved(name)):
686
+ candidates = " or ".join(located(other) for other in shared[import_name])
687
+ message = f"'{import_name}' imported here is {candidates}: calls into it are not followed"
688
+ notes.append(_note("ambiguous-module", message, graph.source(name), 1))
689
+ return notes
690
+
691
+
647
692
  def _seed(results: Mapping[str, CachedModule], graph: ModuleGraph, component: frozenset[str]) -> SummaryIndex:
648
693
  """The summaries a component starts from: those of every module it imports,
649
- transitively, all final by the time its wave runs; the whole index for no component."""
650
-
651
- if component:
652
- wanted: set[str] = set()
653
- pending = list(component)
654
- while pending:
655
- for imported in graph.imports(pending.pop()):
656
- if imported in results and imported not in wanted:
657
- wanted.add(imported)
658
- pending.append(imported)
659
- else:
660
- wanted = set(results)
694
+ transitively, all final by the time its wave runs, each under the import name the
695
+ component's code refers to it by. Of several files with one import name, only the
696
+ one a member imports directly, resolved in its own root, goes under that name: the
697
+ others are reached through summaries that inline them already, and a name no
698
+ member's root resolved is followed into neither file."""
699
+
700
+ wanted: set[str] = set()
701
+ pending = list(component)
702
+ while pending:
703
+ for imported in graph.imports(pending.pop()):
704
+ if imported in results and imported not in wanted:
705
+ wanted.add(imported)
706
+ pending.append(imported)
707
+ direct = {imported for member in component for imported in graph.imports(member)}
708
+ by_import: dict[str, list[str]] = {}
709
+ for name in wanted:
710
+ by_import.setdefault(graph.source(name).module_name, []).append(name)
711
+ names: dict[str, str] = {}
712
+ for import_name, modules in by_import.items():
713
+ chosen = modules if len(modules) == 1 else [name for name in modules if name in direct]
714
+ if len(chosen) == 1:
715
+ names[chosen[0]] = import_name
716
+ return _index(results, names)
717
+
718
+
719
+ def _index(results: Mapping[str, CachedModule], names: Mapping[str, str]) -> SummaryIndex:
720
+ """The summaries of the modules in ``names``, each function under the project symbol
721
+ of the module name given for its module."""
722
+
661
723
  return SummaryIndex(
662
724
  {
663
- project_symbol(name, function): summary
664
- for name in sorted(wanted)
725
+ project_symbol(module, function): summary
726
+ for name, module in sorted(names.items())
665
727
  for function, summary in results[name].summaries.items()
666
728
  }
667
729
  )
@@ -674,8 +736,12 @@ def _analyse_managers(
674
736
  affected: Affected,
675
737
  ) -> dict[str, CachedModule]:
676
738
  """Analyse one component: iterate its summaries to a fixpoint over ``seed`` (§21),
677
- then extract what the rest of the run needs from each module."""
739
+ then extract what the rest of the run needs from each module. Each member registers
740
+ its functions under its import name, what the others' code refers to it by; a name
741
+ two members share, a cycle across roots, is registered by neither."""
678
742
 
743
+ counts = Counter(manager.module.name for manager in managers.values())
744
+ names = {name: manager.module.name for name, manager in managers.items() if counts[manager.module.name] == 1}
679
745
  index = seed
680
746
  for manager in managers.values():
681
747
  manager.provide(ProjectSummaries, index)
@@ -684,9 +750,9 @@ def _analyse_managers(
684
750
  {
685
751
  **seed.summaries,
686
752
  **{
687
- project_symbol(name, function): summary
688
- for name, manager in managers.items()
689
- for function, summary in _summaries_of(manager).items()
753
+ project_symbol(module, function): summary
754
+ for name, module in names.items()
755
+ for function, summary in _summaries_of(managers[name]).items()
690
756
  },
691
757
  }
692
758
  )
@@ -701,8 +767,9 @@ def _analyse_managers(
701
767
 
702
768
  @dataclass(frozen=True)
703
769
  class _Batch:
704
- """One component handed to a worker process: it rebuilds the configuration from the
705
- project root and the plugin roots, so only paths and the imported summaries travel."""
770
+ """One component handed to a worker process, its files under the engine's module
771
+ names: it rebuilds the configuration from the project root and the plugin roots, so
772
+ only paths and the imported summaries travel."""
706
773
 
707
774
  root: Path
708
775
  plugin_roots: tuple[Path, ...]
@@ -1,10 +1,12 @@
1
1
  """Analysis coverage: which files and functions a run actually looked at.
2
2
 
3
3
  "No findings" only means something when the reader knows what was analysed. Each file
4
- is ``analysed``, a ``syntax-error`` (the frontend rejected it) or ``unreadable`` (it could
5
- not be decoded); analysed files count their functions and how many lowered. The
6
- templates the project names but the run could not read are listed, each where the
7
- project names it: they may call any template filter.
4
+ is ``analysed``, ``ambiguous`` (analysed, but another file has its Python module name,
5
+ so a symbol of that name may denote a function of either and an import of it resolves
6
+ only from its own root), a ``syntax-error`` (the frontend rejected it) or ``unreadable``
7
+ (it could not be decoded); analysed and ambiguous files count their functions and how
8
+ many lowered. The templates the project names but the run could not read are listed,
9
+ each where the project names it: they may call any template filter.
8
10
  """
9
11
 
10
12
  from __future__ import annotations
@@ -19,6 +19,7 @@ from coretrace_python.interprocedural.modulegraph import (
19
19
  build_module_graph,
20
20
  discover_files,
21
21
  discover_sources,
22
+ name_modules,
22
23
  project_symbol,
23
24
  )
24
25
  from coretrace_python.interprocedural.summaries import (
@@ -73,5 +74,6 @@ __all__ = [
73
74
  "cleared_by",
74
75
  "discover_files",
75
76
  "discover_sources",
77
+ "name_modules",
76
78
  "project_symbol",
77
79
  ]
@@ -132,12 +132,16 @@ class SignaturesAnalysis(Analysis[tuple[Signature, ...]]):
132
132
  @dataclass(frozen=True)
133
133
  class ModuleFunction:
134
134
  """One function of a module as a project plugin sees it: the name the call graph
135
- gives it (``Class.method``, ``outer.inner``, ``<module>``), where it is, and the label
136
- of the entry point it is (``http`` for a route, ``argv`` for a command), if any."""
135
+ gives it (``Class.method``, ``outer.inner``, ``<module>``), where it is, the label
136
+ of the entry point it is (``http`` for a route, ``argv`` for a command), if any, and
137
+ the module-level ``aliases`` bound to it by assigning one name to another
138
+ (``main = actual``), transitively and in source order; a method or a nested
139
+ function has none."""
137
140
 
138
141
  name: str
139
142
  span: SourceSpan
140
143
  entry_point: str | None = None
144
+ aliases: tuple[str, ...] = ()
141
145
 
142
146
 
143
147
  class CallGraph:
@@ -6,12 +6,18 @@ each module imports. Functions defined in the project get project symbols
6
6
  (``python.app.helpers.run``) whose summaries live in a ``SummaryIndex`` that the engine
7
7
  provides to every module's manager, so calls into other files are analysed through
8
8
  summaries rather than by retaining every module's PyIR.
9
+
10
+ The identity of a module is its file. Two files may have the same import name
11
+ (``a/app.py`` and ``z/app.py`` outside any package are both ``app``, each from its own
12
+ directory): the engine then names them by their path from the root, ``a.app`` and
13
+ ``z.app``, and an import of ``app`` resolves within the importer's own root only.
9
14
  """
10
15
 
11
16
  from __future__ import annotations
12
17
 
18
+ from collections import Counter
13
19
  from collections.abc import Iterable, Mapping
14
- from dataclasses import dataclass
20
+ from dataclasses import dataclass, field
15
21
  from pathlib import Path
16
22
  from types import MappingProxyType
17
23
 
@@ -67,6 +73,66 @@ def _is_environment(directory: Path, known: dict[Path, bool]) -> bool:
67
73
  return known[directory]
68
74
 
69
75
 
76
+ def name_modules(root: Path, sources: Iterable[SourceFile]) -> dict[str, SourceFile]:
77
+ """One name per file, in the order given: its import name when no other file has it;
78
+ otherwise its path from ``root`` as a dotted name (``a/app.py`` and ``z/app.py`` are
79
+ ``a.app`` and ``z.app``, a root-level ``manage.py`` stays ``manage``), or that path
80
+ itself when even the dotted names coincide (``x.y/app.py`` and ``x/y/app.py``).
81
+ Decided over the whole set, so the names do not depend on the order the files are
82
+ found in."""
83
+
84
+ found = tuple(sources)
85
+ counts = Counter(source.module_name for source in found)
86
+ shared = [source for source in found if counts[source.module_name] > 1]
87
+ paths = {source.source_id: _relative(root, source) for source in shared}
88
+ dotted = {source_id: _dotted(path) for source_id, path in paths.items()}
89
+ taken = (
90
+ {name for name, count in counts.items() if count == 1}
91
+ | {name for name, count in Counter(dotted.values()).items() if count > 1}
92
+ | {path.as_posix() for path in paths.values()}
93
+ )
94
+ named: dict[str, SourceFile] = {}
95
+ for source in found:
96
+ if counts[source.module_name] == 1:
97
+ named[source.module_name] = source
98
+ elif dotted[source.source_id] not in taken:
99
+ named[dotted[source.source_id]] = source
100
+ else:
101
+ named[paths[source.source_id].as_posix()] = source
102
+ return named
103
+
104
+
105
+ def _relative(root: Path, source: SourceFile) -> Path:
106
+ path = source.path if source.path is not None else Path(str(source.source_id))
107
+ try:
108
+ return path.relative_to(root.resolve())
109
+ except ValueError:
110
+ return path
111
+
112
+
113
+ def _dotted(path: Path) -> str:
114
+ """``a/app.py`` as ``a.app`` and ``pkg/__init__.py`` as ``pkg``; a root-level
115
+ ``__init__.py`` has no dotted name and keeps its path."""
116
+
117
+ parts = path.with_suffix("").parts
118
+ if parts and parts[-1] == "__init__":
119
+ parts = parts[:-1]
120
+ return ".".join(parts) or path.as_posix()
121
+
122
+
123
+ def _import_root(source: SourceFile) -> Path | None:
124
+ """The directory an import of ``source`` by its module name starts from: the parent
125
+ of its top-level package, or its own directory for a module outside any package,
126
+ where ``module_name_for`` stopped. ``None`` for a source not on disk."""
127
+
128
+ if source.path is None:
129
+ return None
130
+ directory = source.path.parent
131
+ while (directory / "__init__.py").is_file():
132
+ directory = directory.parent
133
+ return directory
134
+
135
+
70
136
  def project_symbol(module_name: str, qualified_name: str) -> SymbolId:
71
137
  """The canonical symbol of a project function. A module whose name is not an
72
138
  identifier cannot be imported by that name, but its functions still need stable
@@ -90,6 +156,7 @@ def _component(part: str) -> str:
90
156
  class ModuleGraph:
91
157
  _sources: Mapping[str, SourceFile]
92
158
  _imports: Mapping[str, frozenset[str]]
159
+ _unresolved: Mapping[str, frozenset[str]] = field(default_factory=dict)
93
160
 
94
161
  @property
95
162
  def modules(self) -> tuple[str, ...]:
@@ -104,6 +171,12 @@ class ModuleGraph:
104
171
  def importers(self, name: str) -> frozenset[str]:
105
172
  return frozenset(m for m, imported in self._imports.items() if name in imported)
106
173
 
174
+ def unresolved(self, name: str) -> frozenset[str]:
175
+ """The import names ``name`` imports that several project files have and not
176
+ exactly one in its own root: no edge records them."""
177
+
178
+ return self._unresolved.get(name, frozenset())
179
+
107
180
  def schedule(self) -> tuple[tuple[frozenset[str], ...], ...]:
108
181
  """Strongly connected components in waves: every component of a wave imports,
109
182
  outside itself, only components of earlier waves, so one wave can be analysed
@@ -167,10 +240,17 @@ def build_module_graph(
167
240
  modules: Mapping[str, nodes.Module],
168
241
  imports: Mapping[str, ImportTable],
169
242
  ) -> ModuleGraph:
170
- """Edges from each module to the project modules it imports, in any scope."""
171
-
172
- names = frozenset(sources)
243
+ """Edges from each module to the project modules it imports, in any scope. An import
244
+ names a module by its import name; when several files have that name, it resolves to
245
+ the one in the importer's own root and, without exactly one there, stays unresolved:
246
+ no edge, and the name is recorded on the graph."""
247
+
248
+ by_name: dict[str, list[str]] = {}
249
+ for name, source in sources.items():
250
+ by_name.setdefault(source.module_name, []).append(name)
251
+ roots = {name: _import_root(source) for name, source in sources.items()}
173
252
  edges: dict[str, frozenset[str]] = {}
253
+ unresolved: dict[str, frozenset[str]] = {}
174
254
  for name, module in modules.items():
175
255
  candidates: set[str] = set()
176
256
  dotted_tops: set[str] = set()
@@ -187,11 +267,24 @@ def build_module_graph(
187
267
  candidates.add(path)
188
268
  # The most specific project module each import names; ``app.helpers`` implies
189
269
  # the ``app`` package, which is not an edge worth recording.
190
- found = {
191
- next((m for m in _prefixes(candidate) if m in names), None) for candidate in candidates
192
- }
193
- edges[name] = frozenset(m for m in found if m is not None and m != name)
194
- return ModuleGraph(MappingProxyType(dict(sources)), MappingProxyType(edges))
270
+ found: set[str] = set()
271
+ missing: set[str] = set()
272
+ for candidate in candidates:
273
+ for prefix in _prefixes(candidate):
274
+ matching = by_name.get(prefix)
275
+ if matching is None:
276
+ continue
277
+ if len(matching) > 1:
278
+ matching = [m for m in matching if roots[m] == roots[name]]
279
+ if len(matching) == 1:
280
+ found.add(matching[0])
281
+ else:
282
+ missing.add(prefix)
283
+ break
284
+ edges[name] = frozenset(found - {name})
285
+ if missing:
286
+ unresolved[name] = frozenset(missing)
287
+ return ModuleGraph(MappingProxyType(dict(sources)), MappingProxyType(edges), MappingProxyType(unresolved))
195
288
 
196
289
 
197
290
  def _prefixes(dotted: str) -> list[str]:
@@ -217,5 +310,6 @@ __all__ = [
217
310
  "SummaryIndex",
218
311
  "build_module_graph",
219
312
  "discover_sources",
313
+ "name_modules",
220
314
  "project_symbol",
221
315
  ]
@@ -113,8 +113,8 @@ class ScopeTable:
113
113
  def children(self, scope_id: ScopeId) -> tuple[Scope, ...]:
114
114
  return self._children[scope_id]
115
115
 
116
- def scope_for(self, node: nodes.Function | nodes.Class | nodes.Comprehension) -> Scope:
117
- """Return the scope introduced by a definition or comprehension node."""
116
+ def scope_for(self, node: nodes.Function | nodes.Class | nodes.Lambda | nodes.Comprehension) -> Scope:
117
+ """Return the scope introduced by a definition, lambda or comprehension node."""
118
118
 
119
119
  return self._scopes[self._spans[node.span]]
120
120
 
@@ -836,7 +836,9 @@ def function_entry_point(
836
836
  ) -> EntryPoint | None:
837
837
  """The entry point ``function`` is: one of its decorators or, for a method, one of its
838
838
  class's bases matching an entry-point model, a registration elsewhere (``routes``), or
839
- a model naming the function, or its class, by its own project symbol."""
839
+ a model naming the function, or its class, by its own project symbol or by a
840
+ module-level alias of it (``main = actual`` names ``actual`` as ``main``). A model
841
+ naming the method wins over one naming its class, whichever name it uses."""
840
842
 
841
843
  owner = _owner(module, function)
842
844
  enclosing_function = _enclosing(module, function)
@@ -844,12 +846,16 @@ def function_entry_point(
844
846
  # ``app = Flask(__name__)`` inside ``create_app``: routes defined there resolve.
845
847
  instances = {**(instances or {}), **local_instances(enclosing_function, scopes, symbols)}
846
848
  entry = entry_point_of(function, models, scopes, symbols, owner, instances, project)
847
- if entry is None:
848
- qualified = function.name if owner is None else f"{owner.name}.{function.name}"
849
- entry = _declared(project_symbol(module.name, qualified), models, routes)
850
- if entry is None and owner is not None:
851
- entry = _declared(project_symbol(module.name, owner.name), models, routes)
852
- return entry
849
+ if entry is not None:
850
+ return entry
851
+ defined = function if owner is None else owner
852
+ names = (defined.name, *_aliases(module, defined.name))
853
+ qualified = names if owner is None else (*(f"{name}.{function.name}" for name in names), *names)
854
+ for name in qualified:
855
+ entry = _declared(project_symbol(module.name, name), models, routes)
856
+ if entry is not None:
857
+ return entry
858
+ return None
853
859
 
854
860
 
855
861
  def _declared(symbol: SymbolId, models: ModelTable, routes: Routes | None) -> EntryPoint | None:
@@ -860,6 +866,43 @@ def _declared(symbol: SymbolId, models: ModelTable, routes: Routes | None) -> En
860
866
  return registered if registered is not None else models.entry_point(symbol)
861
867
 
862
868
 
869
+ def _aliases(module: nodes.Module, name: str) -> Iterable[str]:
870
+ """The module-level names bound to ``name`` by assigning one name to another,
871
+ transitively and in source order: ``handler = actual`` then ``main = handler`` make
872
+ both aliases of ``actual``. A name bound inside a function or class, or to anything
873
+ but a name (a call, an import), is no alias."""
874
+
875
+ aliases: dict[str, list[str]] = {}
876
+ for statement in _module_level(module.body):
877
+ if isinstance(statement, nodes.Assign) and isinstance(statement.target, nodes.Name) and isinstance(statement.value, nodes.Name):
878
+ aliases.setdefault(statement.value.identifier, []).append(statement.target.identifier)
879
+ found = {name}
880
+ pending = [name]
881
+ while pending:
882
+ for alias in aliases.get(pending.pop(0), ()):
883
+ if alias not in found:
884
+ found.add(alias)
885
+ pending.append(alias)
886
+ yield alias
887
+
888
+
889
+ def _module_level(body: Iterable[nodes.Statement]) -> Iterable[nodes.Statement]:
890
+ """The statements executed at module level: the body and what its compound
891
+ statements hold, not what a function or class defines."""
892
+
893
+ for statement in body:
894
+ if isinstance(statement, nodes.Function | nodes.Class):
895
+ continue
896
+ yield statement
897
+ for attribute in ("body", "orelse", "finalbody"):
898
+ nested = getattr(statement, attribute, None)
899
+ if isinstance(nested, tuple):
900
+ yield from _module_level(nested)
901
+ if isinstance(statement, nodes.Try):
902
+ for handler in statement.handlers:
903
+ yield from _module_level(handler.body)
904
+
905
+
863
906
  def _owner(module: nodes.Module, function: nodes.Function) -> nodes.Class | None:
864
907
  """The top-level class ``function`` is a method of, if any."""
865
908
 
@@ -1109,7 +1152,9 @@ class EntryPointAnalysis(Analysis[tuple[ModuleFunction, ...]]):
1109
1152
  functions: list[ModuleFunction] = []
1110
1153
  for name, function in graph.definitions.items():
1111
1154
  entry = function_entry_point(function, ctx.module, models, scopes, symbols, instances, routes, project)
1112
- functions.append(ModuleFunction(name, function.span, entry.label if entry is not None else None))
1155
+ label = entry.label if entry is not None else None
1156
+ # A dotted name, a method's or a nested function's, is bound by no assignment.
1157
+ functions.append(ModuleFunction(name, function.span, label, tuple(_aliases(ctx.module, name))))
1113
1158
  return tuple(functions)
1114
1159
 
1115
1160
 
@@ -24,18 +24,30 @@ calls ``django.template.defaultfilters.striptags``. Every template found counts,
24
24
  or not a Python call names it, since a class-based view renders its ``template_name``
25
25
  inside Django. A template the engine cannot read may call any filter; the engine says
26
26
  where the project names one.
27
+
28
+ A project tag library, a module of a ``templatetags`` directory, replaces a filter of
29
+ that name in every template loading it before applying the filter, as Django's parser
30
+ does: ``{% load custom %}{{ bio|striptags }}`` calls the project's ``striptags`` when
31
+ ``custom.py`` registers one, a later load overrides an earlier one, and
32
+ ``{% load striptags from custom %}`` selects. The engine reads what a library registers
33
+ on its ``register`` with the semantic layers, and where it cannot tell — no such library,
34
+ a file it cannot read or understand, several apps giving the name and disagreeing — the
35
+ filter is neither the built-in nor certainly replaced: no call is claimed, and the load
36
+ is listed among what the engine could not read. ``OPTIONS["builtins"]`` and
37
+ ``OPTIONS["libraries"]`` of the settings are not read.
27
38
  """
28
39
 
29
40
  from __future__ import annotations
30
41
 
31
42
  import re
32
- from collections.abc import Iterable, Iterator, Mapping
43
+ from collections.abc import Container, Iterable, Iterator, Mapping
33
44
  from dataclasses import dataclass, field
34
45
  from pathlib import Path
35
46
  from types import MappingProxyType
36
47
  from typing import ClassVar
37
48
 
38
49
  from coretrace_python.analysis import Analysis, AnalysisContext
50
+ from coretrace_python.frontend import HIRBuildError, ParseError, build_hir
39
51
  from coretrace_python.hir import nodes
40
52
  from coretrace_python.hir.visitors import Node, children
41
53
  from coretrace_python.interprocedural import (
@@ -44,11 +56,21 @@ from coretrace_python.interprocedural import (
44
56
  TemplateFilter,
45
57
  discover_files,
46
58
  )
47
- from coretrace_python.semantic.symbols import SymbolId
48
- from coretrace_python.source import SourceId, SourceSpan
59
+ from coretrace_python.semantic.imports import ImportResolutionError, analyze_imports
60
+ from coretrace_python.semantic.scopes import (
61
+ BindingKind,
62
+ ResolutionKind,
63
+ ScopeError,
64
+ ScopeId,
65
+ ScopeTable,
66
+ analyze_scopes,
67
+ )
68
+ from coretrace_python.semantic.symbols import SymbolId, analyze_symbols
69
+ from coretrace_python.source import SourceId, SourceManager, SourceSpan
49
70
  from coretrace_python.taint.models import ModelTable
50
71
 
51
72
  TEMPLATES_DIRECTORY = "templates"
73
+ TEMPLATETAGS_DIRECTORY = "templatetags"
52
74
 
53
75
  _OUTPUT, _TAG, _URL = "\x00", "\x01", "/u"
54
76
  _SYNTAX = re.compile(r"{{(.*?)}}|{%(.*?)%}|{#.*?#}", re.DOTALL)
@@ -102,6 +124,10 @@ FILTER_FUNCTIONS: Mapping[str, SymbolId] = MappingProxyType(
102
124
  # Every filter but ``safe`` and ``safeseq`` escapes its input, or returns it for
103
125
  # autoescaping to escape, when autoescaping is on.
104
126
  _FILTERS = frozenset(FILTER_FUNCTIONS) - {"safe", "safeseq"}
127
+ # The class a project tag library binds ``register`` to, by either of its paths, and
128
+ # its methods registering tags, which register no filter.
129
+ _LIBRARY_CLASSES = frozenset(SymbolId(f"python.django.template.{p}") for p in ("Library", "library.Library"))
130
+ _TAG_REGISTRARS = frozenset({"tag", "tag_function", "simple_tag", "simple_block_tag", "inclusion_tag"})
105
131
 
106
132
  _URL_ATTRIBUTES = frozenset(
107
133
  {
@@ -131,6 +157,8 @@ _BINDING = re.compile(r"(\w+)=")
131
157
  # the request context processor adds gives (``request.GET``).
132
158
  _VARIABLE = re.compile(r"([A-Za-z_]\w*)(?:\.(\w+))?")
133
159
  _LITERALS = frozenset({"True", "False", "None"})
160
+ # The variable a block renders to keep the parent's content, as ``_chain`` reads it.
161
+ _BLOCK_SUPER = "block.super"
134
162
 
135
163
  REQUEST_PROCESSOR = "django.template.context_processors.request"
136
164
  _DJANGO_ENGINE = "django.template.backends.django.DjangoTemplates"
@@ -314,10 +342,11 @@ class FilterCall:
314
342
  @dataclass(frozen=True)
315
343
  class ProjectTemplates:
316
344
  """The templates found under the project's ``templates`` directories: their
317
- ``names``, as a render call names them; the filters they apply, as ``calls``; where
318
- they name a template the engine cannot read, as ``unread``; and, by name, the filter
319
- calls rendering each template makes with the context keys reaching them, as
320
- ``filters``."""
345
+ ``names``, as a render call names them; the filters of Django's own libraries they
346
+ apply, as ``calls``, unless a project library they load replaces the filter; where
347
+ they name a template the engine cannot read, or load a library it cannot read while
348
+ applying a filter of that name, as ``unread``; and, by name, the filter calls
349
+ rendering each template makes with the context keys reaching them, as ``filters``."""
321
350
 
322
351
  names: frozenset[str] = frozenset()
323
352
  calls: tuple[FilterCall, ...] = ()
@@ -363,13 +392,44 @@ class _Reference:
363
392
  return frozenset(found)
364
393
 
365
394
 
395
+ @dataclass(frozen=True)
396
+ class _Load:
397
+ """A ``{% load %}`` of ``library`` at ``offset``, of the ``names`` it selects
398
+ (``{% load a b from lib %}``), None for the whole library."""
399
+
400
+ library: str
401
+ names: frozenset[str] | None
402
+ offset: int
403
+
404
+
405
+ @dataclass(frozen=True)
406
+ class _Library:
407
+ """What the project's tag library files of one name register: the filter names they
408
+ surely register, ``certain``, and those they may register, ``uncertain`` — None when
409
+ they may register anything, as a file the engine cannot read or understand may."""
410
+
411
+ certain: frozenset[str] = frozenset()
412
+ uncertain: frozenset[str] | None = None
413
+
414
+ def overrides(self, name: str) -> bool | None:
415
+ """Whether loading the library replaces the filter ``name``; None when it may."""
416
+
417
+ if name in self.certain:
418
+ return True
419
+ return None if self.uncertain is None or name in self.uncertain else False
420
+
421
+
366
422
  @dataclass(frozen=True)
367
423
  class _Template:
368
- """What one template file applies and references, and the ``blocks`` it defines."""
424
+ """What one template file applies and references, the ``blocks`` it defines, and
425
+ among them the ``extended`` ones, which render ``{{ block.super }}``, and the loads
426
+ under which it applies a filter the engine could not resolve."""
369
427
 
370
428
  applications: tuple[_Application, ...]
371
429
  references: tuple[_Reference, ...]
372
430
  blocks: frozenset[str]
431
+ extended: frozenset[str] = frozenset()
432
+ unresolved: tuple[_Load, ...] = ()
373
433
 
374
434
  def rendered(self, blocks: tuple[str, ...], overridden: frozenset[str]) -> bool:
375
435
  """Whether what stands in ``blocks`` renders when a template extending this one
@@ -379,15 +439,25 @@ class _Template:
379
439
  extends = any(reference.extends for reference in self.references)
380
440
  return not overridden.intersection(blocks) and (bool(blocks) or not extends)
381
441
 
442
+ @property
443
+ def replaced(self) -> frozenset[str]:
444
+ """The blocks this template replaces in what it extends: those it defines
445
+ without rendering ``{{ block.super }}``, which keeps the parent's content."""
446
+
447
+ return self.blocks - self.extended
448
+
382
449
 
383
450
  def project_templates(root: Path) -> ProjectTemplates:
384
451
  """What the templates under ``root`` call, and the templates they include or extend
385
452
  that the engine cannot read: named by an expression, or by a name found under no
386
- ``templates`` directory. A template file that cannot be read is unread too. A name
453
+ ``templates`` directory. A template file that cannot be read is unread too, and so
454
+ is a template loading a library the engine cannot read, or find, or finds in several
455
+ apps disagreeing, where it then applies a filter of that library's name. A name
387
456
  found in several files gives no ``filters``: which one renders depends on the
388
457
  loaders."""
389
458
 
390
459
  found = {path: tuple(_names(path.relative_to(root).parts)) for path in discover_files(root)}
460
+ libraries = _libraries(found)
391
461
  templates = sorted(path for path, names in found.items() if names)
392
462
  files: dict[str, list[Path]] = {}
393
463
  for path in templates:
@@ -401,13 +471,17 @@ def project_templates(root: Path) -> ProjectTemplates:
401
471
  if text is None:
402
472
  unread.append(f"{relative}, unreadable")
403
473
  continue
404
- read[path] = _read_scopes(text, SourceId(str(path)))
474
+ read[path] = _read_scopes(text, SourceId(str(path)), libraries)
405
475
  for reference in read[path].references:
406
476
  where = f"{relative}:{_position(text, reference.offset)[0]}"
407
477
  if reference.name is None:
408
478
  unread.append(f"{where} names a template by an expression")
409
479
  elif reference.name not in files:
410
480
  unread.append(f"{where} names {reference.name!r}, not found")
481
+ for load in read[path].unresolved:
482
+ library = libraries.get(load.library)
483
+ reason = "not found" if library is None else "not read" if library.uncertain is None else "found in several files"
484
+ unread.append(f"{relative}:{_position(text, load.offset)[0]} loads {load.library!r}, {reason}")
411
485
  calls = tuple(FilterCall(a.symbol, a.span) for template in read.values() for a in template.applications)
412
486
  filters = {
413
487
  name: tuple(
@@ -444,7 +518,7 @@ def _reaching(
444
518
  if reference.name is None:
445
519
  continue
446
520
  if reference.extends:
447
- found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.blocks)
521
+ found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.replaced)
448
522
  elif template.rendered(reference.blocks, overridden):
449
523
  found = _reaching(reference.name, files, read, visiting | {name})
450
524
  else:
@@ -453,18 +527,23 @@ def _reaching(
453
527
  yield symbol, span, where, reference.maps(value), reference.maps(argument)
454
528
 
455
529
 
456
- def _read_scopes(text: str, source: SourceId) -> _Template:
530
+ def _read_scopes(text: str, source: SourceId, libraries: Mapping[str, _Library]) -> _Template:
457
531
  """The filters a template applies and the templates it includes or extends, with the
458
532
  names free in it that reach each: through ``{% for %}``, ``{% with %}`` and the
459
533
  ``as`` of other tags, which bind a name until their block ends, the arguments of
460
- earlier filters, and the output of ``{% filter %}`` blocks."""
534
+ earlier filters, and the output of ``{% filter %}`` blocks. A filter is the one of
535
+ Django's own libraries unless a project library, among ``libraries``, loaded before
536
+ it in the file replaces it, as Django's parser resolves filters."""
461
537
 
462
538
  applications: list[_Application] = []
463
539
  references: list[_Reference] = []
464
540
  opened: list[str] = []
465
541
  defined: set[str] = set()
542
+ extended: set[str] = set()
466
543
  scopes: list[dict[str, frozenset[str]]] = [{}]
467
544
  blocks: list[tuple[list[tuple[str, int, frozenset[str]]], set[str]]] = []
545
+ loaded: list[_Load] = []
546
+ unresolved: dict[_Load, None] = {}
468
547
 
469
548
  def free(names: frozenset[str]) -> frozenset[str]:
470
549
  found: set[str] = set()
@@ -474,14 +553,36 @@ def _read_scopes(text: str, source: SourceId) -> _Template:
474
553
  found |= {name} if bound is None else _through(bound, name)
475
554
  return frozenset(found)
476
555
 
556
+ def behind(name: str) -> SymbolId | _Load | None:
557
+ """The function behind the filter ``name`` applied here: the one of Django's own
558
+ libraries, unless the latest load so far of a library registering ``name``
559
+ replaces it (None), or may (that load). Django's own libraries register no name
560
+ of another, so their loads change nothing."""
561
+
562
+ builtin = FILTER_FUNCTIONS.get(name)
563
+ if builtin is None:
564
+ return None
565
+ for load in reversed(loaded):
566
+ if (load.names is not None and name not in load.names) or load.library in _LIBRARIES:
567
+ continue
568
+ library = libraries.get(load.library)
569
+ verdict = library.overrides(name) if library is not None else None
570
+ if verdict is None:
571
+ return load
572
+ if verdict:
573
+ return None
574
+ return builtin
575
+
477
576
  def apply(head: frozenset[str], chain: list[tuple[str, int, frozenset[str]]]) -> frozenset[str]:
478
577
  """Record the filters of ``chain`` applied to a value the free names ``head``
479
578
  reach; the free names reaching the value of the whole expression."""
480
579
 
481
580
  value = head
482
581
  for name, offset, argument in chain:
483
- symbol = FILTER_FUNCTIONS.get(name)
484
- if symbol is not None:
582
+ symbol = behind(name)
583
+ if isinstance(symbol, _Load):
584
+ unresolved[symbol] = None
585
+ elif symbol is not None:
485
586
  line, column = _position(text, offset)
486
587
  span = SourceSpan(source, line, column)
487
588
  applications.append(_Application(symbol, span, value, free(argument), tuple(opened)))
@@ -491,6 +592,8 @@ def _read_scopes(text: str, source: SourceId) -> _Template:
491
592
  for offset, expression, tag in _expressions(text):
492
593
  if not tag:
493
594
  head, chain = _chain(expression, offset)
595
+ if opened and _BLOCK_SUPER in head:
596
+ extended.add(opened[-1])
494
597
  printed = apply(free(head), chain)
495
598
  for _, collected in blocks:
496
599
  collected |= printed
@@ -543,10 +646,145 @@ def _read_scopes(text: str, source: SourceId) -> _Template:
543
646
  else:
544
647
  context = {k: v for scope in scopes for k, v in scope.items()}
545
648
  references.append(_Reference(named, offset, bindings, "only" in words, context, tuple(opened)))
649
+ elif name == "load":
650
+ selected = frozenset(words[1 : words.index("from")]) if "from" in words else None
651
+ loaded.extend(_Load(library, selected, offset) for library in _loaded(words[1:]))
546
652
  elif "as" in words[1:-1]:
547
653
  target = words[words.index("as", 1) + 1]
548
654
  scopes[-1][target] = values.get(words[1], frozenset()) if name == "regroup" else frozenset()
549
- return _Template(tuple(applications), tuple(references), frozenset(defined))
655
+ return _Template(
656
+ tuple(applications), tuple(references), frozenset(defined), frozenset(extended), tuple(unresolved)
657
+ )
658
+
659
+
660
+ def _libraries(paths: Iterable[Path]) -> dict[str, _Library]:
661
+ """The project's tag libraries among ``paths``, by the name ``{% load %}`` gives
662
+ them: the modules of the ``templatetags`` directories. Of several files of one name,
663
+ in different apps, only what every file registers is certain, and one the engine
664
+ cannot read leaves everything uncertain."""
665
+
666
+ readings: dict[str, list[frozenset[str] | None]] = {}
667
+ for path in paths:
668
+ if path.suffix == ".py" and path.parent.name == TEMPLATETAGS_DIRECTORY and path.stem != "__init__":
669
+ readings.setdefault(path.stem, []).append(_registered_filters(path))
670
+ libraries: dict[str, _Library] = {}
671
+ for name, found in readings.items():
672
+ read = [names for names in found if names is not None]
673
+ if len(read) < len(found):
674
+ libraries[name] = _Library()
675
+ else:
676
+ certain = read[0].intersection(*read[1:])
677
+ libraries[name] = _Library(certain, read[0].union(*read[1:]) - certain)
678
+ return libraries
679
+
680
+
681
+ def _registered_filters(path: Path) -> frozenset[str] | None:
682
+ """The names of the filters the tag library module ``path`` registers, or None when
683
+ the engine cannot tell them all: the file cannot be read or parsed, ``register`` is
684
+ not bound to a ``Library`` at module level, a registration's name is not written as
685
+ a constant, or the library is filled or passed around in a way the engine does not
686
+ read. The ``Library`` is known by its symbol, whatever the import spelling, and a
687
+ registration under ``if``, ``try`` or a function counts."""
688
+
689
+ try:
690
+ module = build_hir(SourceManager().load_file(path))
691
+ scopes = analyze_scopes(module)
692
+ symbols = analyze_symbols(scopes, analyze_imports(module, scopes), module)
693
+ except (OSError, UnicodeDecodeError, ParseError, HIRBuildError, ScopeError, ImportResolutionError):
694
+ return None
695
+ scope = scopes.module_scope.id
696
+ if symbols.resolve(scope, "register") not in _LIBRARY_CLASSES:
697
+ return None
698
+ functions = {name for name, bound in scopes.module_scope.bindings.items() if bound.kind is BindingKind.FUNCTION}
699
+ registered: set[str] = set()
700
+
701
+ def record(name: str | None) -> bool:
702
+ if name is not None:
703
+ registered.add(name)
704
+ return name is not None
705
+
706
+ def visit(node: Node) -> bool:
707
+ """Read the registrations under ``node``; False when one cannot be read."""
708
+
709
+ if isinstance(node, nodes.Function):
710
+ for decorator in node.decorators:
711
+ if _library_attribute(symbols.resolve_expression(scope, decorator)) == "filter":
712
+ named = _decorated(decorator, node.name) if isinstance(decorator, nodes.Call) else node.name
713
+ if not record(named):
714
+ return False
715
+ elif not visit(decorator):
716
+ return False
717
+ return all(map(visit, (*node.parameters, *node.body)))
718
+ if isinstance(node, nodes.Call) and isinstance(node.callee, nodes.Name | nodes.Attribute):
719
+ callee = symbols.resolve_expression(scope, node.callee)
720
+ if callee in _LIBRARY_CLASSES:
721
+ return not (node.arguments or node.keywords) # ``Library(filters=...)`` fills it unseen
722
+ if _library_attribute(callee) == "filter":
723
+ return record(_registered(node, functions)) and all(map(visit, (*node.arguments, *node.keywords)))
724
+ if isinstance(node, nodes.Name | nodes.Attribute):
725
+ symbol = symbols.resolve_expression(scope, node)
726
+ if _library_attribute(symbol) in _TAG_REGISTRARS:
727
+ return True
728
+ if symbol in _LIBRARY_CLASSES or _library_attribute(symbol) is not None:
729
+ return False # the library, or its filters, in hands the engine does not follow
730
+ if isinstance(node, nodes.Assign) and isinstance(node.target, nodes.Name):
731
+ return visit(node.value)
732
+ return all(map(visit, children(node)))
733
+
734
+ return frozenset(registered) if all(map(visit, module.body)) else None
735
+
736
+
737
+ def _library_attribute(symbol: SymbolId | None) -> str | None:
738
+ """The attribute of a ``Library`` that ``symbol`` names, ``filter`` for
739
+ ``register.filter``; None for any other symbol."""
740
+
741
+ if symbol is None:
742
+ return None
743
+ owner, _, name = symbol.canonical_name.rpartition(".")
744
+ return name if "." in owner and SymbolId(owner) in _LIBRARY_CLASSES else None
745
+
746
+
747
+ def _decorated(call: nodes.Call, function: str) -> str | None:
748
+ """What ``@register.filter(...)`` registers ``function`` as: the constant ``name`` it
749
+ gives, or ``function`` itself when it gives only flags; None when the engine cannot
750
+ tell."""
751
+
752
+ given = _arguments(call, "name")
753
+ if given is None:
754
+ return None
755
+ return function if given[0] is None else _string(given[0])
756
+
757
+
758
+ def _registered(call: nodes.Call, functions: Container[str]) -> str | None:
759
+ """What ``register.filter(...)`` registers when called as a statement: the constant
760
+ name of ``register.filter("x", fn)`` and ``register.filter(name="x", filter_func=fn)``,
761
+ the function's own name for ``register.filter(fn)`` when the module defines
762
+ ``fn``; None when the engine cannot tell."""
763
+
764
+ given = _arguments(call, "name", "filter_func")
765
+ if given is None:
766
+ return None
767
+ name, function = given
768
+ if function is None:
769
+ return name.identifier if isinstance(name, nodes.Name) and name.identifier in functions else None
770
+ return _string(name)
771
+
772
+
773
+ def _arguments(call: nodes.Call, *parameters: str) -> list[nodes.Expression | None] | None:
774
+ """The argument ``call`` gives each of ``parameters``, by position or keyword; None
775
+ when it unpacks any, which could give them too."""
776
+
777
+ if any(isinstance(a, nodes.Starred) for a in call.arguments) or any(k.name is None for k in call.keywords):
778
+ return None
779
+ keywords = {keyword.name: keyword.value for keyword in call.keywords}
780
+ return [
781
+ call.arguments[position] if position < len(call.arguments) else keywords.get(name)
782
+ for position, name in enumerate(parameters)
783
+ ]
784
+
785
+
786
+ def _string(expression: nodes.Expression | None) -> str | None:
787
+ return expression.value if isinstance(expression, nodes.Constant) and isinstance(expression.value, str) else None
550
788
 
551
789
 
552
790
  def _chain(expression: str, offset: int) -> tuple[frozenset[str], list[tuple[str, int, frozenset[str]]]]:
@@ -578,29 +816,41 @@ def _through(bound: frozenset[str], name: str) -> frozenset[str]:
578
816
  return frozenset(".".join(f"{b}.{attribute}".split(".")[:2]) if attribute else b for b in bound)
579
817
 
580
818
 
581
- def request_processor(modules: Iterable[nodes.Module]) -> bool:
819
+ def request_processor(modules: Iterable[tuple[nodes.Module, ScopeTable]]) -> bool:
582
820
  """Whether every Django template engine the project's settings configure runs the
583
- ``request`` context processor, for certain: each assignment to ``TEMPLATES`` is a
584
- literal list of literal engines, at least one of them ``DjangoTemplates``, and each
585
- of those lists the processor in a literal ``context_processors``; no other code
586
- names ``TEMPLATES``, which could change it. A mere mention of the processor proves
587
- nothing."""
821
+ ``request`` context processor, for certain: each module-level assignment to
822
+ ``TEMPLATES`` is a literal list of literal engines, at least one of them
823
+ ``DjangoTemplates``, and each of those lists the processor in a literal
824
+ ``context_processors``; no other code names the module's ``TEMPLATES``, or a
825
+ ``TEMPLATES`` it does not bind (a star import), which could change it. A
826
+ ``TEMPLATES`` bound in a function or a class is another name, which configures
827
+ nothing and changes nothing. A mere mention of the processor proves nothing."""
588
828
 
589
829
  assignments = mentions = 0
590
- for module in modules:
591
- stack: list[Node] = list(module.body)
592
- while stack:
593
- node = stack.pop()
594
- stack.extend(children(node))
830
+ for module, scopes in modules:
831
+ for node, scope in _scoped(module, scopes):
595
832
  if isinstance(node, nodes.Name) and node.identifier == _ENGINES_SETTING:
596
- mentions += 1
597
- elif isinstance(node, nodes.Assign) and _names_engines(node.target):
833
+ if scopes.resolve(scope, node.identifier).kind in (ResolutionKind.GLOBAL, ResolutionKind.UNBOUND):
834
+ mentions += 1
835
+ elif isinstance(node, nodes.Assign) and _names_engines(node.target) and scope == scopes.module_scope.id:
598
836
  if not _runs_request_processor(node.value):
599
837
  return False
600
838
  assignments += 1
601
839
  return assignments > 0 and mentions == assignments
602
840
 
603
841
 
842
+ def _scoped(module: nodes.Module, scopes: ScopeTable) -> Iterator[tuple[Node, ScopeId]]:
843
+ """Every node of ``module`` with the scope it stands in."""
844
+
845
+ stack: list[tuple[Node, ScopeId]] = [(node, scopes.module_scope.id) for node in module.body]
846
+ while stack:
847
+ node, scope = stack.pop()
848
+ yield node, scope
849
+ if isinstance(node, nodes.Function | nodes.Lambda | nodes.Class | nodes.Comprehension):
850
+ scope = scopes.scope_for(node).id
851
+ stack.extend((child, scope) for child in children(node))
852
+
853
+
604
854
  def _names_engines(target: nodes.Expression) -> bool:
605
855
  return isinstance(target, nodes.Name) and target.identifier == _ENGINES_SETTING
606
856
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coretrace-python-analyzer
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: Static security analysis infrastructure for Python code
5
5
  Project-URL: Homepage, https://github.com/CoreTrace/coretrace-python-analyzer
6
6
  Project-URL: Repository, https://github.com/CoreTrace/coretrace-python-analyzer
@@ -1,8 +1,8 @@
1
- coretrace_python/__init__.py,sha256=UXlC7yIk3dOxWBAjttYzugsg0Cacccz_IG6V-LsaqSU,76
1
+ coretrace_python/__init__.py,sha256=FWsRdqY0ZoJRj8rA--2DntUA3GU1JsrZdyrjQIAq5wE,76
2
2
  coretrace_python/__main__.py,sha256=V8DnYtRboISK2VCKm7DGHLREKvqLbqApyWlKBOs26wU,65
3
- coretrace_python/cache.py,sha256=-pZv-ELm4jMCTXQWSZIobHaT9UHIjFu7IlMkOM0i4LI,13607
3
+ coretrace_python/cache.py,sha256=XnUC677v8VQvlmLA_GQO376isHn6ZdSOOA0dRua0Foo,13716
4
4
  coretrace_python/cli.py,sha256=G5jhb6g6RgSRSjjS7DBgziIjUM1vTFwalJGvFHVKxrg,11225
5
- coretrace_python/engine.py,sha256=13lCa7rJTI6Q4Icyvs8gZpcJhW9WxO1poWXGjWCDHT0,33922
5
+ coretrace_python/engine.py,sha256=i_YckOVHZa_Mcwxgi2jsMkw8O49zHFQZ313mA_Xtl-4,37643
6
6
  coretrace_python/abstract/__init__.py,sha256=n5FNkV8Z4LmwG0r06KWdLj4N2CPSgAKwY3896tdmRgM,999
7
7
  coretrace_python/abstract/constants.py,sha256=z4-e7IzysxzGi58wFFff1MlEoV8l7AZsB9EL-q6b7vY,9071
8
8
  coretrace_python/abstract/heap.py,sha256=OaESXFGC0qzyhAr76xrCu4skeDVzZM4wlenQqfMTkdk,9642
@@ -102,7 +102,7 @@ coretrace_python/dependency/sbom.py,sha256=rzJ-t0Mwoghej8nk0N63Dpjr_wYXm6JzuOPXj
102
102
  coretrace_python/dependency/vex.py,sha256=bafWyziWWQP-Fjw1bvyOcOPsdFMwKy-sjMGWL7IPY3w,8048
103
103
  coretrace_python/findings/__init__.py,sha256=yFKXSFEsAnFSxF1E_ptF2OH4YlUcCaSeIlWSvqMgaHo,500
104
104
  coretrace_python/findings/baseline.py,sha256=fbGQuULRsCjhxSNdwuLfwsvql9ZUfF3jqCXrRmIgOoQ,3468
105
- coretrace_python/findings/coverage.py,sha256=c7-NXdESYCzJWsSDx5EhflY6Lr7yr0XSxwOMPUDEE18,1385
105
+ coretrace_python/findings/coverage.py,sha256=LT74vTEjnt_kAgWBEt60rM0sB6mFAyIVzhta0WNUkxE,1580
106
106
  coretrace_python/findings/model.py,sha256=zRK1FDuLgiVY4ch6H_AN9rDxC1orNW4wc8YN3cfVFfc,1424
107
107
  coretrace_python/findings/provenance.py,sha256=vP3nbyDPMlRlxVcVlurF0mSGNuxO3B7kzDDDd_BVws0,838
108
108
  coretrace_python/findings/refutation.py,sha256=UeYCz70LwyL8AMimhs3ys9cH0j1_YbdHVu7y3VvmmUg,22026
@@ -113,9 +113,9 @@ coretrace_python/frontend/parser.py,sha256=6-CG7rWxUYeOGeDP206SJWy4BMQUP9Mr6Jf-t
113
113
  coretrace_python/hir/__init__.py,sha256=xMpvj-RSp6bd78a_hhkqbe99-snjERkH-_Lv5o9ASbw,180
114
114
  coretrace_python/hir/nodes.py,sha256=4BI0f3UhKbnz9ZIxMoKXM7ujav0BmRpD0OXaoblRxIk,12364
115
115
  coretrace_python/hir/visitors.py,sha256=JFnX09hK6trLyRcWgZ5ehOG2ywX3VvkqQ1h82nahYNg,885
116
- coretrace_python/interprocedural/__init__.py,sha256=HvV9zibV_VX8cfIYzWXVEDGkkMZzDywx-lKJioYSFtg,1566
117
- coretrace_python/interprocedural/callgraph.py,sha256=a7fTvciUjMkEO0TsJqZm6L0OFyARmLQ1P2cjAnBTENk,21948
118
- coretrace_python/interprocedural/modulegraph.py,sha256=4sB7OKjBYfxtm7aMtHm2jRlqFVUNUsdnLhnv9UZ1BWA,8846
116
+ coretrace_python/interprocedural/__init__.py,sha256=q8u1eIpM55wqVtn46cxuymOn5D5DWU7K35i8Nzzbc3w,1604
117
+ coretrace_python/interprocedural/callgraph.py,sha256=EEG8kh9yyFPZiiFcouR8wuIwicClSKHu-z7nLh1kKOQ,22163
118
+ coretrace_python/interprocedural/modulegraph.py,sha256=xKlO_L2mcZi0RoG9h2ulacYecqUhM5BG1RrdC6_HUk8,13036
119
119
  coretrace_python/interprocedural/summaries.py,sha256=6lybEynjtptZGGBw_3WKXx9ZZZqN96xL-r-tzo8fbMI,33862
120
120
  coretrace_python/ir/__init__.py,sha256=ShBPYYqoQFePP-mgBjGezQ2acel0-ReOHF9vCC-G_4Q,232
121
121
  coretrace_python/ir/defuse.py,sha256=CZEPnlgntRp4MqwR7kLldd2ZlIlBnr-V9VJAxXxwm0c,2616
@@ -138,20 +138,20 @@ coretrace_python/reporters/text.py,sha256=bHsHkMheyL0AB1iWtxBO5H9S14k1AqJvKkNsFr
138
138
  coretrace_python/semantic/__init__.py,sha256=tJOvNvMzJ9yjP2bgkLpPO29pl9gMQgICh6XVYJHrovw,464
139
139
  coretrace_python/semantic/identity.py,sha256=uQBdqEXrOtYmGIiR9hZu0Pimv52iW1rOOOvIdG0InNY,1472
140
140
  coretrace_python/semantic/imports.py,sha256=389MZs8iTUc3CyUHG0VAgWFsypd7VPFKkLHmFz9L-N0,5673
141
- coretrace_python/semantic/scopes.py,sha256=zg8ClITSAUbOMvLOV5BsfdG1oC4cd0VweUb9S5Jc4js,20241
141
+ coretrace_python/semantic/scopes.py,sha256=-ZP88ppAjSBUPB5TvsznuP8YmCXU-LzSq6acq8iM95w,20264
142
142
  coretrace_python/semantic/symbols.py,sha256=G0jwsWFo2346Y1R2sztsB8HuZZm_aHfHSJXXeZKPQLo,7354
143
143
  coretrace_python/source/__init__.py,sha256=0Y8fvtbkETnNCJR9Zq7RS5TSgF9c22pz3SSk1KdAXF0,301
144
144
  coretrace_python/source/manager.py,sha256=JRmMOcWfS1nocbVrdcvfQTqEfIkmnb5JP857TpGywN4,2736
145
145
  coretrace_python/source/model.py,sha256=FZnN5mlir3tzbIpWoxnn6YtRi9UcJ_y0sZM60tKr8U4,1727
146
146
  coretrace_python/taint/__init__.py,sha256=nKpD33_4IRneAkd3Dt-j7Q-cHl47rDA_eLZFDHZVH28,1827
147
- coretrace_python/taint/engine.py,sha256=BHNdoyUryK9lDOZ-lq235GdTC9SDbeUHquoDH2DDCM8,49457
147
+ coretrace_python/taint/engine.py,sha256=InunQrFgPq5NmiNkn0BQTTb_7D4IY_wZ2sJZn_K4iMs,51533
148
148
  coretrace_python/taint/models.py,sha256=pCvE-6dYKYSLK7pylxhLWpeUcMfMyXlh_1VUJbzieo8,20405
149
149
  coretrace_python/taint/routes.py,sha256=UAJKXcw_sM9shf58FfDroxVO9bNSm4qrQdZknwUB0lo,6110
150
- coretrace_python/taint/templates.py,sha256=74y06rKs2F0cCOqRw_jbKwlZ8M4nUgsatOFX5ZWkRNQ,31344
150
+ coretrace_python/taint/templates.py,sha256=gUpUvVk_UIVY2a3T5ENm2TfBuPCDhnu2lu8h50srZ4Q,43963
151
151
  coretrace_python/taint/urls.py,sha256=kb0-uOT12ww7R3Jzs_lPKg9lFs74JI-bpa9pXX105xM,3166
152
- coretrace_python_analyzer-0.18.0.dist-info/METADATA,sha256=UknDXxCfvCkshCAt4gMWAGWWGp_SzsiN9laP98Gq9qo,3217
153
- coretrace_python_analyzer-0.18.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
154
- coretrace_python_analyzer-0.18.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
155
- coretrace_python_analyzer-0.18.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
156
- coretrace_python_analyzer-0.18.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
157
- coretrace_python_analyzer-0.18.0.dist-info/RECORD,,
152
+ coretrace_python_analyzer-0.19.0.dist-info/METADATA,sha256=QWl44X6mXg-aIRsdgMZIGyX-Ms3CvvpysboxggSRJi4,3217
153
+ coretrace_python_analyzer-0.19.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
154
+ coretrace_python_analyzer-0.19.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
155
+ coretrace_python_analyzer-0.19.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
156
+ coretrace_python_analyzer-0.19.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
157
+ coretrace_python_analyzer-0.19.0.dist-info/RECORD,,