attackmap-analyzer-python 0.2.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.
@@ -0,0 +1,667 @@
1
+ """Django URLconf include() graph (mlaify/attackmap-analyzer-python#2).
2
+
3
+ Django mounts one URLconf inside another with ``include()``; the child's
4
+ patterns only exist under the parent's prefix. This module reads every URLconf
5
+ (and every DRF router module) with :mod:`ast`, records what each module-level
6
+ pattern list contains, and then walks the include graph from the root
7
+ URLconfs so each leaf route carries its full prefix:
8
+
9
+ - ``path("api/", include("shop.api_urls"))`` — a dotted module path
10
+ - ``include(api_urls)`` / ``include(views.urlpatterns)`` — an imported module
11
+ - ``include(router.urls)`` / ``urlpatterns = router.urls`` — a DRF router
12
+ - ``include((patterns, "app"))`` — the ``(patterns, app_name)`` tuple form
13
+ - ``include([path(...), ...])`` and ``include(local_list)`` — inline lists
14
+ - ``re_path`` / legacy ``url`` regex segments (anchors are stripped on join)
15
+
16
+ A resolved ``include()`` line is a prefix, not an endpoint, so it is no
17
+ longer emitted as a route. An include that can't be resolved inside the repo
18
+ (``include("allauth.urls")``, ``include(get_model_urls(...))``) is still
19
+ emitted as its prefix so the mounted surface stays visible.
20
+
21
+ Within the bounds below, no route the regex extractor used to report is
22
+ lost:
23
+
24
+ - A pattern list is the *union* of everything assigned to it. Reassignments,
25
+ if/else and try/except alternatives, and wrappers
26
+ (``urlpatterns = decorate_urlpatterns(urlpatterns, ...)``) all add; nothing
27
+ replaces. A route that every branch replaces is still reported, which trades
28
+ a little precision for recall.
29
+ - A list, router or module that is never reached from a root URLconf is still
30
+ emitted, unprefixed (the pre-#2 behaviour), so a dynamic mount the graph
31
+ can't follow keeps its routes.
32
+
33
+ Everything is bounded: entries per list and per file, registrations per
34
+ router, include depth, mounts per list, steps (router registrations included)
35
+ and routes. Hitting a bound truncates instead of hanging. Nothing here touches
36
+ the filesystem: module references resolve only against the files the analyzer
37
+ already read.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import ast
43
+ import posixpath
44
+ from dataclasses import dataclass, field
45
+
46
+ from ._pyast import call_name, dotted, safe_parse, str_const
47
+
48
+ # name → is the first argument a regex?
49
+ _URL_FUNCS = {"path": False, "re_path": True, "url": True}
50
+ # Calls that wrap a pattern list and return it (or a superset of it).
51
+ # `patterns("view.prefix", url(...), ...)` is the pre-1.10 form.
52
+ _PASSTHROUGH_FUNCS = {"i18n_patterns", "format_suffix_patterns", "patterns", "list", "tuple"}
53
+
54
+ # Auth wrappers applied to a view right in the URLconf (#256):
55
+ # `path("x/", login_required(views.x))`, `permission_required("p")(View.as_view())`.
56
+ # Django 5.1's `login_not_required` marks a view explicitly public.
57
+ _AUTH_WRAPPERS = {"login_required", "permission_required", "staff_member_required", "user_passes_test"}
58
+ _PUBLIC_WRAPPERS = {"login_not_required"}
59
+ _MAX_WRAP_DEPTH = 6
60
+
61
+ MAX_DEPTH = 12
62
+ # Per-file model bounds: entries in one pattern list, entries across a file's
63
+ # lists, and registrations on one router. A real URLconf is far below these.
64
+ MAX_LIST_ENTRIES = 5_000
65
+ MAX_FILE_ENTRIES = 20_000
66
+ MAX_ROUTER_REGISTRATIONS = 5_000
67
+ MAX_PREFIXES_PER_LIST = 64
68
+ MAX_STEPS = 200_000
69
+ MAX_ROUTES = 50_000
70
+ _MAX_EXPR_DEPTH = 64
71
+ _ORPHANS = "<orphans>"
72
+
73
+
74
+ @dataclass
75
+ class Target:
76
+ """What an ``include()`` (or a bare list/attribute mount) points at."""
77
+
78
+ kind: str # "module" | "name" | "attr" | "inline" | "unknown"
79
+ value: str = "" # dotted module, local/imported name, or attribute base
80
+ attr: str = "" # for kind="attr": the attribute (`urls`, `urlpatterns`)
81
+ entries: list["Entry"] = field(default_factory=list) # kind="inline"
82
+
83
+
84
+ @dataclass
85
+ class Entry:
86
+ kind: str # "leaf" | "include" | "mount"
87
+ line: int
88
+ segment: str | None = None # None: a non-literal route (e.g. settings.BASE_PATH)
89
+ regex: bool = False
90
+ target: Target | None = None
91
+ auth: str = "unknown"
92
+ guard: str | None = None
93
+
94
+
95
+ @dataclass
96
+ class UrlConf:
97
+ rel: str
98
+ is_urlconf: bool
99
+ emit_unmounted_routers: bool
100
+ lists: dict[str, list[Entry]] = field(default_factory=dict)
101
+ # router variable → [(prefix, line)] from `router.register("prefix", ViewSet)`
102
+ routers: dict[str, list[tuple[str, int]]] = field(default_factory=dict)
103
+ # bound name → (relative-import level, module, imported attribute or "")
104
+ imports: dict[str, tuple[int, str, str]] = field(default_factory=dict)
105
+ truncated: bool = False # a per-file cap was hit
106
+
107
+
108
+ @dataclass
109
+ class DjangoRoute:
110
+ path: str
111
+ file: str
112
+ line: int
113
+ auth: str = "unknown"
114
+ guards: list[str] = field(default_factory=list)
115
+ evidence: str | None = None
116
+ method: str = "ANY" # a URLconf doesn't say which verbs the view takes
117
+
118
+
119
+ # ---------- Parsing ----------
120
+
121
+
122
+ def parse_urlconf(content: str, rel: str, *, is_urlconf: bool, drf: bool) -> UrlConf | None:
123
+ """Build the URLconf model for one file, or ``None`` when it can't be
124
+ parsed (the caller then falls back to the regex extractor)."""
125
+ tree = safe_parse(content)
126
+ if tree is None:
127
+ return None
128
+ try:
129
+ return _Builder(rel, is_urlconf=is_urlconf, drf=drf).build(tree)
130
+ except RecursionError:
131
+ return None
132
+
133
+
134
+ class _Builder:
135
+ def __init__(self, rel: str, *, is_urlconf: bool, drf: bool):
136
+ self.conf = UrlConf(rel=rel, is_urlconf=is_urlconf, emit_unmounted_routers=drf or is_urlconf)
137
+ self.consumed: set[int] = set()
138
+ self._list_ids: dict[str, set[int]] = {}
139
+ self._total = 0
140
+
141
+ def build(self, tree: ast.Module) -> UrlConf:
142
+ for stmt in _module_statements(tree.body):
143
+ self._statement(stmt)
144
+ # path()/re_path()/url() calls the list model didn't account for (inside
145
+ # a function, a comprehension, a dynamic container): keep emitting them
146
+ # unprefixed, as the regex extractor always did.
147
+ orphans: list[Entry] = []
148
+ for node in ast.walk(tree):
149
+ if isinstance(node, ast.Call) and id(node) not in self.consumed and self._is_url_call(node):
150
+ entry = self._url_entry(node, 0)
151
+ # An empty route here (e.g. inside an AppConfig.get_urls()) sits at
152
+ # a mount point we can't see; claiming "/" would be wrong.
153
+ if entry is not None and entry.segment:
154
+ orphans.append(entry)
155
+ if orphans and self.conf.is_urlconf:
156
+ self._extend(_ORPHANS, orphans)
157
+ return self.conf
158
+
159
+ # -- statements --
160
+
161
+ def _statement(self, stmt: ast.stmt) -> None:
162
+ conf = self.conf
163
+ if isinstance(stmt, ast.ImportFrom):
164
+ for alias in stmt.names:
165
+ if alias.name != "*":
166
+ conf.imports[alias.asname or alias.name] = (stmt.level or 0, stmt.module or "", alias.name)
167
+ elif isinstance(stmt, ast.Import):
168
+ for alias in stmt.names:
169
+ if alias.asname:
170
+ conf.imports[alias.asname] = (0, alias.name, "")
171
+ else:
172
+ head = alias.name.split(".")[0]
173
+ conf.imports[head] = (0, head, "")
174
+ elif isinstance(stmt, (ast.Assign, ast.AnnAssign)):
175
+ targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
176
+ value = stmt.value
177
+ if value is None:
178
+ return
179
+ for target in targets:
180
+ if isinstance(target, ast.Name):
181
+ self._assign(target.id, value)
182
+ elif isinstance(stmt, ast.AugAssign) and isinstance(stmt.op, ast.Add) and isinstance(stmt.target, ast.Name):
183
+ name = stmt.target.id
184
+ entries = self._entries(stmt.value, name, 0)
185
+ if entries or name in conf.lists:
186
+ self._extend(name, entries)
187
+ elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
188
+ self._call_statement(stmt.value)
189
+
190
+ def _assign(self, name: str, value: ast.expr) -> None:
191
+ conf = self.conf
192
+ callee = call_name(value) if isinstance(value, ast.Call) else None
193
+ if callee and callee.endswith("Router") and callee != "Router":
194
+ conf.routers.setdefault(name, [])
195
+ return
196
+ entries = self._entries(value, name, 0)
197
+ if entries or name == "urlpatterns" or (isinstance(value, (ast.List, ast.Tuple)) and not value.elts):
198
+ # Union, never replace: a reassignment, an if/else or try/except
199
+ # alternative, or `urlpatterns = wrap(urlpatterns)` keeps every
200
+ # route any branch defines (precision trade-off: a route replaced
201
+ # on every path is still reported).
202
+ self._extend(name, entries)
203
+
204
+ def _extend(self, name: str, entries: list[Entry]) -> None:
205
+ """Add entries to a list once each, within the per-list/per-file caps."""
206
+ target = self.conf.lists.setdefault(name, [])
207
+ ids = self._list_ids.setdefault(name, set())
208
+ for entry in entries:
209
+ if id(entry) in ids:
210
+ continue
211
+ if len(target) >= MAX_LIST_ENTRIES or self._total >= MAX_FILE_ENTRIES:
212
+ self.conf.truncated = True
213
+ return
214
+ ids.add(id(entry))
215
+ target.append(entry)
216
+ self._total += 1
217
+
218
+ def _call_statement(self, call: ast.Call) -> None:
219
+ func = call.func
220
+ if not isinstance(func, ast.Attribute) or not isinstance(func.value, ast.Name):
221
+ return
222
+ owner, method = func.value.id, func.attr
223
+ conf = self.conf
224
+ if method == "register" and call.args:
225
+ prefix = str_const(call.args[0])
226
+ if prefix is None or len(call.args) + len(call.keywords) < 2:
227
+ return
228
+ # A locally built router, or one named like the conventional `router`.
229
+ if owner in conf.routers or owner.lower().endswith("router"):
230
+ regs = conf.routers.setdefault(owner, [])
231
+ if len(regs) < MAX_ROUTER_REGISTRATIONS:
232
+ regs.append((prefix, call.lineno))
233
+ else:
234
+ conf.truncated = True
235
+ return
236
+ if method in {"append", "extend", "insert"} and call.args:
237
+ arg = call.args[-1]
238
+ if method == "extend":
239
+ entries = self._entries(arg, owner, 0)
240
+ else:
241
+ entries = self._entries(ast.List(elts=[arg], ctx=ast.Load()), owner, 0)
242
+ if entries or owner in conf.lists:
243
+ self._extend(owner, entries)
244
+
245
+ # -- expressions --
246
+
247
+ def _entries(self, node: ast.expr, self_name: str, depth: int) -> list[Entry]:
248
+ """The URL entries an expression contributes to a pattern list."""
249
+ if depth > _MAX_EXPR_DEPTH:
250
+ return []
251
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
252
+ out: list[Entry] = []
253
+ for elt in node.elts:
254
+ if isinstance(elt, ast.Starred):
255
+ out.extend(self._entries(elt.value, self_name, depth + 1))
256
+ elif isinstance(elt, ast.Call) and self._is_url_call(elt):
257
+ entry = self._url_entry(elt, depth + 1)
258
+ if entry is not None:
259
+ out.append(entry)
260
+ else:
261
+ out.extend(self._entries(elt, self_name, depth + 1))
262
+ return out
263
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
264
+ return self._entries(node.left, self_name, depth + 1) + self._entries(node.right, self_name, depth + 1)
265
+ if isinstance(node, ast.Name):
266
+ if node.id == self_name:
267
+ # `urlpatterns = format_suffix_patterns(urlpatterns)` / `x = x + x`:
268
+ # the list already holds these (lists are unions), add nothing.
269
+ return []
270
+ return [Entry("mount", getattr(node, "lineno", 0), segment="", target=Target("name", node.id))]
271
+ if isinstance(node, ast.Attribute) and node.attr in {"urls", "urlpatterns"}:
272
+ base = dotted(node.value)
273
+ if base:
274
+ return [Entry("mount", node.lineno, segment="", target=Target("attr", base, node.attr))]
275
+ return []
276
+ if isinstance(node, ast.Call):
277
+ if self._is_url_call(node):
278
+ entry = self._url_entry(node, depth + 1)
279
+ return [entry] if entry is not None else []
280
+ # Known wrappers (`i18n_patterns`, `format_suffix_patterns`, ...) and
281
+ # unknown ones (`decorate_urlpatterns(api, login_required)`) alike:
282
+ # whatever pattern lists they're handed stay mounted. Non-pattern
283
+ # arguments resolve to nothing.
284
+ out = []
285
+ for arg in node.args:
286
+ inner = arg.value if isinstance(arg, ast.Starred) else arg
287
+ out.extend(self._entries(inner, self_name, depth + 1))
288
+ return out
289
+ return []
290
+
291
+ @staticmethod
292
+ def _is_url_call(node: ast.Call) -> bool:
293
+ func = node.func
294
+ if isinstance(func, ast.Name):
295
+ name = func.id
296
+ elif isinstance(func, ast.Attribute) and not (isinstance(func.value, ast.Name) and func.value.id == "os"):
297
+ name = func.attr
298
+ else:
299
+ return False
300
+ return name in _URL_FUNCS and len(node.args) + len(node.keywords) >= 2
301
+
302
+ def _url_entry(self, call: ast.Call, depth: int) -> Entry | None:
303
+ self.consumed.add(id(call))
304
+ regex = _URL_FUNCS[call_name(call) or "path"]
305
+ route = call.args[0] if call.args else _kw(call, "route", "regex")
306
+ view = call.args[1] if len(call.args) > 1 else _kw(call, "view")
307
+ if route is None or view is None:
308
+ return None
309
+ segment = str_const(route)
310
+ if isinstance(view, ast.Call) and call_name(view) == "include":
311
+ self.consumed.add(id(view))
312
+ target_node = view.args[0] if view.args else _kw(view, "arg")
313
+ target = self._include_target(target_node, depth + 1) if target_node is not None else Target("unknown")
314
+ return Entry("include", call.lineno, segment=segment, regex=regex, target=target)
315
+ auth, guard = _wrapper_auth(view)
316
+ return Entry("leaf", call.lineno, segment=segment, regex=regex, auth=auth, guard=guard)
317
+
318
+ def _include_target(self, node: ast.expr, depth: int) -> Target:
319
+ if depth > _MAX_EXPR_DEPTH:
320
+ return Target("unknown")
321
+ module = str_const(node)
322
+ if module is not None:
323
+ return Target("module", module)
324
+ if isinstance(node, ast.Tuple) and 2 <= len(node.elts) <= 3 and str_const(node.elts[1]) is not None:
325
+ # include((patterns, "app_name")) / include(("mod.urls", "app"), namespace=...)
326
+ return self._include_target(node.elts[0], depth + 1)
327
+ if isinstance(node, (ast.List, ast.Tuple)):
328
+ return Target("inline", entries=self._entries(node, "", depth + 1))
329
+ if isinstance(node, ast.Name):
330
+ return Target("name", node.id)
331
+ if isinstance(node, ast.Attribute):
332
+ base = dotted(node.value)
333
+ if base:
334
+ return Target("attr", base, node.attr)
335
+ if isinstance(node, ast.Call) and call_name(node) in _PASSTHROUGH_FUNCS:
336
+ return Target("inline", entries=self._entries(node, "", depth + 1))
337
+ return Target("unknown")
338
+
339
+
340
+ def _wrapper_auth(view: ast.expr) -> tuple[str, str | None]:
341
+ """`login_required(v)` / `permission_required("p")(v)` / nested wrappers
342
+ around the view in the URLconf → ("required" | "anonymous", wrapper)."""
343
+ node = view
344
+ for _ in range(_MAX_WRAP_DEPTH):
345
+ if not isinstance(node, ast.Call):
346
+ break
347
+ func = node.func
348
+ # `permission_required("p")(view)`: the decorator factory is func.func.
349
+ name = call_name(func.func) if isinstance(func, ast.Call) else call_name(func)
350
+ if name in _AUTH_WRAPPERS:
351
+ return "required", name
352
+ if name in _PUBLIC_WRAPPERS:
353
+ return "anonymous", name
354
+ if not node.args:
355
+ break
356
+ node = node.args[0]
357
+ return "unknown", None
358
+
359
+
360
+ def _kw(call: ast.Call, *names: str) -> ast.expr | None:
361
+ for kw in call.keywords:
362
+ if kw.arg in names:
363
+ return kw.value
364
+ return None
365
+
366
+
367
+ def _module_statements(body: list[ast.stmt], depth: int = 0):
368
+ """Module-level statements, descending into `if settings.DEBUG:` /
369
+ `try:` / `with` / `for` blocks but not into functions or classes."""
370
+ if depth > 16:
371
+ return
372
+ for stmt in body:
373
+ yield stmt
374
+ if isinstance(stmt, (ast.If, ast.For, ast.While)):
375
+ yield from _module_statements(stmt.body, depth + 1)
376
+ yield from _module_statements(stmt.orelse, depth + 1)
377
+ elif isinstance(stmt, ast.With):
378
+ yield from _module_statements(stmt.body, depth + 1)
379
+ elif isinstance(stmt, ast.Try) or type(stmt).__name__ == "TryStar":
380
+ yield from _module_statements(stmt.body, depth + 1)
381
+ for handler in stmt.handlers:
382
+ yield from _module_statements(handler.body, depth + 1)
383
+ yield from _module_statements(stmt.orelse, depth + 1)
384
+ yield from _module_statements(stmt.finalbody, depth + 1)
385
+
386
+
387
+ # ---------- Path joining ----------
388
+
389
+
390
+ def _strip_anchors(segment: str) -> str:
391
+ if segment.startswith("\\A"):
392
+ segment = segment[2:]
393
+ segment = segment.lstrip("^")
394
+ if segment.endswith("\\Z"):
395
+ segment = segment[:-2]
396
+ while segment.endswith("$") and not segment.endswith("\\$"):
397
+ segment = segment[:-1]
398
+ return segment
399
+
400
+
401
+ def join_segments(segments: list[tuple[str, bool]]) -> str:
402
+ """Concatenate URLconf segments the way Django does (no separator added),
403
+ stripping regex anchors, and return a single-leading-slash path."""
404
+ joined = "".join(_strip_anchors(text) if regex else text for text, regex in segments)
405
+ path = "/" + joined.lstrip("/")
406
+ while "//" in path:
407
+ path = path.replace("//", "/")
408
+ return path
409
+
410
+
411
+ # ---------- Graph ----------
412
+
413
+
414
+ class UrlGraph:
415
+ def __init__(self, confs: dict[str, UrlConf], project_dirs: list[str]):
416
+ self.confs = confs
417
+ # Directories holding manage.py: Django's sys.path roots, tried first
418
+ # when resolving an absolute dotted module.
419
+ self._project_dirs = set(project_dirs)
420
+ self._anchor_cache: dict[str, list[str]] = {}
421
+ self.routes: list[DjangoRoute] = []
422
+ self._seen: set[tuple[str, str, int]] = set()
423
+ self._expanded: set[tuple[str, str]] = set()
424
+ self._prefix_count: dict[tuple[str, str], int] = {}
425
+ self._mounted_routers: set[tuple[str, str]] = set()
426
+ self._router_mounts: set[tuple[str, str, str]] = set()
427
+ self._steps = 0
428
+ self.truncated = False
429
+
430
+ # -- resolution --
431
+
432
+ def _module_rel(self, from_rel: str, level: int, module: str) -> str | None:
433
+ parts = [p for p in module.split(".") if p]
434
+ if level:
435
+ base = posixpath.dirname(from_rel)
436
+ for _ in range(level - 1):
437
+ base = posixpath.dirname(base)
438
+ anchors = [base]
439
+ else:
440
+ if not parts:
441
+ return None
442
+ anchors = self._absolute_anchors(from_rel)
443
+ for anchor in anchors:
444
+ stem = posixpath.join(anchor, *parts) if parts else anchor
445
+ if not stem:
446
+ continue
447
+ for cand in (stem + ".py", posixpath.join(stem, "__init__.py")):
448
+ if cand in self.confs:
449
+ return cand
450
+ return None
451
+
452
+ def _absolute_anchors(self, from_rel: str) -> list[str]:
453
+ """manage.py directories above the file (deepest first), the repo
454
+ root, then the file's ancestors. Indexed, cached per directory."""
455
+ directory = posixpath.dirname(from_rel)
456
+ cached = self._anchor_cache.get(directory)
457
+ if cached is not None:
458
+ return cached
459
+ ancestors: list[str] = []
460
+ d = directory
461
+ while d:
462
+ ancestors.append(d)
463
+ d = posixpath.dirname(d)
464
+ project = [a for a in ancestors if a in self._project_dirs]
465
+ if "" in self._project_dirs:
466
+ project.append("")
467
+ anchors = list(dict.fromkeys(project + [""] + ancestors))
468
+ self._anchor_cache[directory] = anchors
469
+ return anchors
470
+
471
+ def _import_module(self, conf: UrlConf, name: str) -> tuple[str | None, str]:
472
+ """For a bound name: (module file it names or comes from, imported attr)."""
473
+ level, module, attr = conf.imports[name]
474
+ if attr:
475
+ sub = f"{module}.{attr}" if module else attr
476
+ rel = self._module_rel(conf.rel, level, sub)
477
+ if rel is not None:
478
+ return rel, "" # `from pkg import api_urls` binds a module
479
+ return self._module_rel(conf.rel, level, module), attr
480
+
481
+ def _resolve(self, conf: UrlConf, target: Target | None):
482
+ """→ ("list", conf, name) | ("router", conf, name) | ("inline", conf, entries) | None"""
483
+ if target is None:
484
+ return None
485
+ kind = target.kind
486
+ if kind == "inline":
487
+ return ("inline", conf, target.entries)
488
+ if kind == "module":
489
+ rel = self._module_rel(conf.rel, 0, target.value)
490
+ return self._urlpatterns_of(rel)
491
+ if kind == "name":
492
+ name = target.value
493
+ if name in conf.lists:
494
+ return ("list", conf, name)
495
+ if name in conf.imports:
496
+ rel, attr = self._import_module(conf, name)
497
+ if rel is None:
498
+ return None
499
+ if not attr:
500
+ return self._urlpatterns_of(rel)
501
+ other = self.confs[rel]
502
+ if attr in other.lists:
503
+ return ("list", other, attr)
504
+ if attr in other.routers:
505
+ return None # include(router) without .urls isn't valid Django
506
+ return None
507
+ if kind == "attr":
508
+ base, attr = target.value, target.attr
509
+ if "." in base:
510
+ return None
511
+ if attr == "urls" and base in conf.routers:
512
+ return ("router", conf, base)
513
+ if base in conf.imports:
514
+ rel, imported = self._import_module(conf, base)
515
+ if rel is None:
516
+ return None
517
+ other = self.confs[rel]
518
+ if not imported:
519
+ # `from . import api` + include(api.urlpatterns)
520
+ return self._urlpatterns_of(rel) if attr == "urlpatterns" else None
521
+ if attr == "urls" and imported in other.routers:
522
+ return ("router", other, imported)
523
+ if attr == "urlpatterns" and imported in other.lists:
524
+ return ("list", other, imported)
525
+ return None
526
+ return None
527
+
528
+ def _urlpatterns_of(self, rel: str | None):
529
+ if rel is None:
530
+ return None
531
+ conf = self.confs[rel]
532
+ if conf.is_urlconf and "urlpatterns" in conf.lists:
533
+ return ("list", conf, "urlpatterns")
534
+ return None
535
+
536
+ # -- expansion --
537
+
538
+ def _emit(self, path: str, file: str, line: int, entry: Entry | None = None) -> None:
539
+ if len(self.routes) >= MAX_ROUTES:
540
+ self.truncated = True
541
+ return
542
+ key = (path, file, line)
543
+ if key in self._seen:
544
+ return
545
+ self._seen.add(key)
546
+ route = DjangoRoute(path, file, line)
547
+ if entry is not None and entry.guard:
548
+ route.auth = entry.auth
549
+ route.guards = [entry.guard] if entry.auth == "required" else []
550
+ route.evidence = f"{entry.guard}(...) in {file}:{line}"
551
+ self.routes.append(route)
552
+
553
+ def _expand_list(self, conf: UrlConf, name: str, prefix: list[tuple[str, bool]], depth: int, stack: frozenset) -> None:
554
+ key = (conf.rel, name)
555
+ if key in stack or depth > MAX_DEPTH:
556
+ return
557
+ count = self._prefix_count.get(key, 0)
558
+ if count >= MAX_PREFIXES_PER_LIST:
559
+ self.truncated = True
560
+ return
561
+ self._prefix_count[key] = count + 1
562
+ self._expanded.add(key)
563
+ stack = stack | {key}
564
+ for entry in conf.lists.get(name, []):
565
+ self._expand_entry(conf, entry, prefix, depth, stack)
566
+
567
+ def _expand_entry(self, conf: UrlConf, entry: Entry, prefix: list[tuple[str, bool]], depth: int, stack: frozenset) -> None:
568
+ self._steps += 1
569
+ if self._steps > MAX_STEPS or len(self.routes) >= MAX_ROUTES:
570
+ self.truncated = True
571
+ return
572
+ if entry.kind == "leaf":
573
+ if entry.segment is not None:
574
+ self._emit(join_segments(prefix + [(entry.segment, entry.regex)]), conf.rel, entry.line, entry)
575
+ return
576
+ resolved = self._resolve(conf, entry.target)
577
+ if entry.kind == "mount":
578
+ if resolved is not None:
579
+ self._expand_resolved(resolved, prefix, depth + 1, stack)
580
+ return
581
+ # include(): a dynamic route (`settings.BASE_PATH`) joins as "".
582
+ child_prefix = prefix + [(entry.segment or "", entry.regex)]
583
+ if resolved is not None:
584
+ self._expand_resolved(resolved, child_prefix, depth + 1, stack)
585
+ elif entry.segment is not None:
586
+ path = join_segments(child_prefix)
587
+ if path != "/":
588
+ self._emit(path, conf.rel, entry.line)
589
+
590
+ def _expand_resolved(self, resolved, prefix: list[tuple[str, bool]], depth: int, stack: frozenset) -> None:
591
+ kind, conf, ref = resolved
592
+ if depth > MAX_DEPTH:
593
+ self.truncated = True
594
+ return
595
+ if kind == "list":
596
+ self._expand_list(conf, ref, prefix, depth, stack)
597
+ elif kind == "inline":
598
+ for entry in ref:
599
+ self._expand_entry(conf, entry, prefix, depth, stack)
600
+ elif kind == "router":
601
+ self._mounted_routers.add((conf.rel, ref))
602
+ mount = (conf.rel, ref, join_segments(prefix))
603
+ if mount in self._router_mounts:
604
+ return # the same router mounted at the same prefix again
605
+ self._router_mounts.add(mount)
606
+ for segment, line in conf.routers.get(ref, []):
607
+ self._steps += 1
608
+ if self._steps > MAX_STEPS or len(self.routes) >= MAX_ROUTES:
609
+ self.truncated = True
610
+ return
611
+ self._emit(join_segments(prefix + [(segment, False)]), conf.rel, line)
612
+
613
+ def _references(self) -> set[tuple[str, str]]:
614
+ """Every (file, list) some include/mount resolves to."""
615
+ refs: set[tuple[str, str]] = set()
616
+ budget = MAX_STEPS
617
+ for conf in self.confs.values():
618
+ stack = [e for entries in conf.lists.values() for e in entries]
619
+ while stack and budget > 0:
620
+ budget -= 1
621
+ entry = stack.pop()
622
+ resolved = self._resolve(conf, entry.target)
623
+ if resolved is None:
624
+ continue
625
+ kind, other, ref = resolved
626
+ if kind == "list" and other is not conf:
627
+ refs.add((other.rel, ref))
628
+ elif kind == "list" and ref != "urlpatterns":
629
+ refs.add((other.rel, ref))
630
+ elif kind == "inline":
631
+ stack.extend(ref)
632
+ return refs
633
+
634
+ def expand(self) -> list[DjangoRoute]:
635
+ refs = self._references()
636
+ ordered = sorted(self.confs.values(), key=lambda c: c.rel)
637
+
638
+ def list_order(conf: UrlConf) -> list[str]:
639
+ names = sorted(conf.lists, key=lambda n: (n != "urlpatterns", n == _ORPHANS, n))
640
+ return names
641
+
642
+ # 1. Root URLconfs: urlpatterns no other URLconf includes.
643
+ for conf in ordered:
644
+ if conf.is_urlconf and "urlpatterns" in conf.lists and (conf.rel, "urlpatterns") not in refs:
645
+ self._expand_list(conf, "urlpatterns", [], 0, frozenset())
646
+ # 2. Anything not reached yet: unreferenced lists first (they may mount
647
+ # the rest under a prefix), then the remainder (include cycles).
648
+ for want_referenced in (False, True):
649
+ for conf in ordered:
650
+ for name in list_order(conf):
651
+ key = (conf.rel, name)
652
+ if key in self._expanded or ((key in refs) != want_referenced):
653
+ continue
654
+ self._expand_list(conf, name, [], 0, frozenset())
655
+ # 3. Routers nobody mounted keep the pre-#2 unprefixed emission.
656
+ for conf in ordered:
657
+ if not conf.emit_unmounted_routers:
658
+ continue
659
+ for name in sorted(conf.routers):
660
+ if (conf.rel, name) in self._mounted_routers:
661
+ continue
662
+ for segment, line in conf.routers[name]:
663
+ self._emit(join_segments([(segment, False)]), conf.rel, line)
664
+ return self.routes
665
+
666
+
667
+ __all__ = ["DjangoRoute", "UrlConf", "UrlGraph", "join_segments", "parse_urlconf"]