apisec-code-bolt 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 (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,806 @@
1
+ """
2
+ Router registry for tracking FastAPI/Starlette routers and their relationships.
3
+
4
+ This module handles:
5
+ - Tracking router variable definitions (app = FastAPI(), router = APIRouter())
6
+ - Tracking include_router() calls with prefix resolution
7
+ - Building the final router tree with merged prefixes
8
+ - Resolving route paths to their final URLs
9
+ - Resolving constant-based prefixes (settings.PREFIX, config.api_prefix)
10
+
11
+ CRITICAL: This enables correct path resolution in multi-file FastAPI applications
12
+ where routers are defined in one file and included with prefixes in another.
13
+
14
+ PHILOSOPHY: Even if prefix values CAN be overridden at runtime, resolve the
15
+ default/static value. This captures the most common deployment configuration.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from ..base import ParsedAssignment, ParsedFile
27
+ from .constant_resolver import ConstantResolver
28
+
29
+
30
+ # =============================================================================
31
+ # Data Types
32
+ # =============================================================================
33
+
34
+
35
+ @dataclass
36
+ class RouterDefinition:
37
+ """Definition of a router or app variable."""
38
+
39
+ name: str # Variable name (e.g., "app", "router", "user_router")
40
+ file_path: Path
41
+ line: int
42
+
43
+ # What type is it?
44
+ router_type: str # "FastAPI", "APIRouter", "Starlette"
45
+
46
+ # Constructor arguments
47
+ prefix: str = "" # For APIRouter(prefix="/api")
48
+ tags: list[str] = field(default_factory=list)
49
+
50
+ # Dependencies declared on the constructor (e.g. APIRouter(dependencies=[...]))
51
+ constructor_dependencies: list[str] = field(default_factory=list)
52
+
53
+ # Qualified name for cross-file reference
54
+ qualified_name: str = ""
55
+
56
+ # Is this a re-assignment?
57
+ source_router: str | None = None # For router = other_router
58
+
59
+
60
+ @dataclass
61
+ class RouterInclusion:
62
+ """Record of an include_router() call."""
63
+
64
+ # Where the inclusion happens
65
+ file_path: Path
66
+ line: int
67
+
68
+ # The parent (app/router being added to)
69
+ parent_var: str
70
+
71
+ # The router being included
72
+ included_router: str # Variable name or module.var reference
73
+ included_router_qualified: str | None = None # Resolved qualified name
74
+
75
+ # Arguments to include_router
76
+ prefix: str = "" # Path prefix to add
77
+ tags: list[str] = field(default_factory=list)
78
+ dependencies: list[str] = field(default_factory=list)
79
+
80
+ # Resolution status
81
+ resolved: bool = False
82
+
83
+
84
+ @dataclass
85
+ class RouterNode:
86
+ """Node in the router tree."""
87
+
88
+ definition: RouterDefinition
89
+
90
+ # Parent router (None for root FastAPI apps)
91
+ parent: RouterNode | None = None
92
+
93
+ # Included routers
94
+ children: list[RouterNode] = field(default_factory=list)
95
+
96
+ # Computed full prefix (including all parent prefixes)
97
+ computed_prefix: str = ""
98
+
99
+ # All tags (inherited + own)
100
+ computed_tags: list[str] = field(default_factory=list)
101
+
102
+ # Accumulated dependency function names from all ancestor include_router()
103
+ # calls. For example, if a parent router is included with
104
+ # ``dependencies=[Depends(get_current_user)]``, every child route
105
+ # inherits ``["get_current_user"]``.
106
+ computed_dependencies: list[str] = field(default_factory=list)
107
+
108
+
109
+ @dataclass
110
+ class RouterTree:
111
+ """Complete router tree for a project."""
112
+
113
+ # All router definitions by qualified name
114
+ routers: dict[str, RouterDefinition] = field(default_factory=dict)
115
+
116
+ # All inclusions
117
+ inclusions: list[RouterInclusion] = field(default_factory=list)
118
+
119
+ # Root nodes (FastAPI apps)
120
+ roots: list[RouterNode] = field(default_factory=list)
121
+
122
+ # Variable name to qualified name mapping per file
123
+ var_to_qualified: dict[Path, dict[str, str]] = field(default_factory=dict)
124
+
125
+
126
+ # =============================================================================
127
+ # Router Registry
128
+ # =============================================================================
129
+
130
+
131
+ class RouterRegistry:
132
+ """
133
+ Tracks router definitions and inclusions across a project.
134
+
135
+ Supports resolving constant-based prefixes like:
136
+ - settings.API_PREFIX
137
+ - config.prefix
138
+ - os.getenv("PREFIX", "/api")
139
+
140
+ Usage:
141
+ registry = RouterRegistry()
142
+
143
+ # Add parsed files
144
+ for parsed in parsed_files:
145
+ registry.process_file(parsed)
146
+
147
+ # Optionally set constant resolver for config values
148
+ registry.set_constant_resolver(constant_resolver)
149
+
150
+ # Build the router tree
151
+ tree = registry.build_tree()
152
+
153
+ # Get the full prefix for a route
154
+ full_path = registry.resolve_path(router_var, route_path, file_path)
155
+ """
156
+
157
+ def __init__(self, project_root: Path | None = None):
158
+ """Initialize the registry."""
159
+ self._project_root = project_root
160
+ self._routers: dict[str, RouterDefinition] = {}
161
+ self._inclusions: list[RouterInclusion] = []
162
+ self._var_to_qualified: dict[Path, dict[str, str]] = {}
163
+ self._tree: RouterTree | None = None
164
+ self._import_mappings: dict[Path, dict[str, str]] = {}
165
+ self._constant_resolver: ConstantResolver | None = None
166
+
167
+ def set_constant_resolver(self, resolver: ConstantResolver) -> None:
168
+ """Set a constant resolver for resolving config-based prefixes."""
169
+ self._constant_resolver = resolver
170
+
171
+ def process_file(self, parsed: ParsedFile) -> None:
172
+ """Process a parsed file to extract router information."""
173
+ if not parsed.success:
174
+ return
175
+
176
+ file_path = parsed.path
177
+ self._var_to_qualified[file_path] = {}
178
+ self._import_mappings[file_path] = {}
179
+
180
+ # Build import mappings first
181
+ self._build_import_mappings(parsed)
182
+
183
+ # Find router definitions in assignments
184
+ for assign in parsed.assignments:
185
+ router_def = self._extract_router_definition(assign, file_path)
186
+ if router_def:
187
+ qualified = self._make_qualified_name(router_def.name, file_path)
188
+ router_def.qualified_name = qualified
189
+ self._routers[qualified] = router_def
190
+ self._var_to_qualified[file_path][router_def.name] = qualified
191
+
192
+ # Find include_router calls
193
+ for call in parsed.call_sites:
194
+ inclusion = self._extract_router_inclusion(call, file_path)
195
+ if inclusion:
196
+ self._inclusions.append(inclusion)
197
+
198
+ def _build_import_mappings(self, parsed: ParsedFile) -> None:
199
+ """Build import alias mappings for the file."""
200
+ file_path = parsed.path
201
+ mappings = {}
202
+
203
+ for imp in parsed.imports:
204
+ if imp.is_from_import:
205
+ for name in imp.names:
206
+ local_name = imp.alias if len(imp.names) == 1 and imp.alias else name
207
+ mappings[local_name] = f"{imp.module}.{name}" if imp.module else name
208
+ else:
209
+ local_name = imp.alias or imp.module.split(".")[-1]
210
+ mappings[local_name] = imp.module
211
+
212
+ self._import_mappings[file_path] = mappings
213
+
214
+ def _extract_router_definition(
215
+ self,
216
+ assign: ParsedAssignment,
217
+ file_path: Path,
218
+ ) -> RouterDefinition | None:
219
+ """Extract router definition from an assignment."""
220
+ if assign.source_type != "call":
221
+ return None
222
+
223
+ called = assign.source_call or ""
224
+
225
+ # Resolve aliases
226
+ resolved = self._resolve_call_name(called, file_path)
227
+
228
+ # Check for router types
229
+ router_type = None
230
+ if resolved in {"FastAPI", "fastapi.FastAPI", "fastapi.applications.FastAPI"}:
231
+ router_type = "FastAPI"
232
+ elif resolved in {"APIRouter", "fastapi.APIRouter", "fastapi.routing.APIRouter"}:
233
+ router_type = "APIRouter"
234
+ elif resolved in {"Starlette", "starlette.applications.Starlette"}:
235
+ router_type = "Starlette"
236
+ elif resolved in {"Router", "starlette.routing.Router"}:
237
+ router_type = "Router"
238
+
239
+ if not router_type:
240
+ return None
241
+
242
+ # Extract constructor arguments
243
+ source = assign.value_source or ""
244
+ prefix = self._extract_string_kwarg(source, "prefix")
245
+ tags = self._extract_list_kwarg(source, "tags")
246
+
247
+ # Extract constructor-level dependencies (e.g. APIRouter(dependencies=[...]))
248
+ constructor_deps: list[str] = []
249
+ dep_match = re.findall(r"Depends\s*\(\s*([\w.]+)\s*\)", source)
250
+ if dep_match and "dependencies" in source:
251
+ constructor_deps = [f"Depends({d})" for d in dep_match]
252
+
253
+ return RouterDefinition(
254
+ name=assign.target,
255
+ file_path=file_path,
256
+ line=assign.location.line,
257
+ router_type=router_type,
258
+ prefix=prefix,
259
+ tags=tags,
260
+ constructor_dependencies=constructor_deps,
261
+ )
262
+
263
+ def _extract_router_inclusion(
264
+ self,
265
+ call,
266
+ file_path: Path,
267
+ ) -> RouterInclusion | None:
268
+ """Extract include_router call information."""
269
+ # Check if this is an include_router call
270
+ callee = call.callee_name
271
+ if not callee.endswith("include_router"):
272
+ return None
273
+
274
+ # Extract parent router name
275
+ parts = callee.rsplit(".", 1)
276
+ parent_var = parts[0] if len(parts) > 1 else ""
277
+
278
+ if not parent_var:
279
+ return None
280
+
281
+ # Extract the router being included (first argument)
282
+ included_router = ""
283
+ prefix = ""
284
+ tags: list[str] = []
285
+ dependencies: list[str] = []
286
+
287
+ for arg in call.arguments:
288
+ if arg.position == 0 or arg.name == "router":
289
+ included_router = arg.variable_name or arg.expression_text or ""
290
+ elif arg.name == "prefix":
291
+ prefix = self._extract_string_value(arg, file_path)
292
+ elif arg.name == "tags":
293
+ tags = self._extract_list_value(arg)
294
+ elif arg.name == "dependencies":
295
+ dependencies = self._extract_list_value(arg)
296
+
297
+ if not included_router:
298
+ return None
299
+
300
+ # Try to resolve qualified name
301
+ qualified = self._resolve_included_router(included_router, file_path)
302
+
303
+ return RouterInclusion(
304
+ file_path=file_path,
305
+ line=call.location.line,
306
+ parent_var=parent_var,
307
+ included_router=included_router,
308
+ included_router_qualified=qualified,
309
+ prefix=prefix,
310
+ tags=tags,
311
+ dependencies=dependencies,
312
+ )
313
+
314
+ def _resolve_call_name(self, name: str, file_path: Path) -> str:
315
+ """Resolve a call name using import mappings."""
316
+ mappings = self._import_mappings.get(file_path, {})
317
+
318
+ parts = name.split(".")
319
+ if parts[0] in mappings:
320
+ resolved_first = mappings[parts[0]]
321
+ if len(parts) > 1:
322
+ return f"{resolved_first}.{'.'.join(parts[1:])}"
323
+ return resolved_first
324
+
325
+ return name
326
+
327
+ def _resolve_import_reference(self, ref: str, file_path: Path) -> str | None:
328
+ """Resolve a module.var reference to a qualified name."""
329
+ mappings = self._import_mappings.get(file_path, {})
330
+
331
+ parts = ref.split(".", 1)
332
+ if parts[0] in mappings:
333
+ module = mappings[parts[0]]
334
+ if len(parts) > 1:
335
+ return f"{module}.{parts[1]}"
336
+ return module
337
+
338
+ return None
339
+
340
+ def _resolve_included_router(
341
+ self,
342
+ included_router: str,
343
+ file_path: Path,
344
+ ) -> str | None:
345
+ """Resolve an included router reference to its qualified name.
346
+
347
+ Tries three strategies in order:
348
+ 1. Dotted reference (``module.router``) — resolve via imports
349
+ 2. Local variable defined in the same file (``my_router = APIRouter()``)
350
+ 3. Import alias (``from views import router as my_router``) — resolve
351
+ the import to a module-qualified name then match against known
352
+ routers registered from other files.
353
+ """
354
+ if "." in included_router:
355
+ return self._resolve_import_reference(included_router, file_path)
356
+
357
+ # Local variable defined in this file
358
+ if file_path in self._var_to_qualified:
359
+ qualified = self._var_to_qualified[file_path].get(included_router)
360
+ if qualified:
361
+ return qualified
362
+
363
+ # Import alias — the variable is imported, not locally defined.
364
+ # _import_mappings maps the local name to a module-qualified path
365
+ # (e.g. "ai_router" → "dispatch.ai.prompt.views.router"). We need
366
+ # to find the matching entry in _routers which uses file-system
367
+ # qualified names (e.g. "src.dispatch.ai.prompt.views.router").
368
+ mappings = self._import_mappings.get(file_path, {})
369
+ module_qualified = mappings.get(included_router)
370
+ if module_qualified:
371
+ matched = self._match_module_to_router(module_qualified)
372
+ if matched:
373
+ return matched
374
+
375
+ return None
376
+
377
+ def _match_module_to_router(self, module_qualified: str) -> str | None:
378
+ """Match a Python module path to a registered router's qualified name.
379
+
380
+ The registry keys are file-system based (``src.dispatch.auth.views.router``)
381
+ while import mappings produce module paths (``dispatch.auth.views.router``).
382
+ We match by checking if any registered router's qualified name ends with
383
+ the module path, handling the ``src.`` / ``src/`` prefix difference.
384
+ """
385
+ # Exact match first (unlikely but cheap)
386
+ if module_qualified in self._routers:
387
+ return module_qualified
388
+
389
+ # Suffix match: "dispatch.auth.views.router" matches
390
+ # "src.dispatch.auth.views.router"
391
+ suffix = f".{module_qualified}"
392
+ for qualified_name in self._routers:
393
+ if qualified_name.endswith(suffix) or qualified_name == module_qualified:
394
+ return qualified_name
395
+
396
+ return None
397
+
398
+ def _make_qualified_name(self, var_name: str, file_path: Path) -> str:
399
+ """Create a qualified name for a variable."""
400
+ # Use file path as module identifier
401
+ rel_path = file_path
402
+ if self._project_root and file_path.is_relative_to(self._project_root):
403
+ rel_path = file_path.relative_to(self._project_root)
404
+
405
+ module = str(rel_path.with_suffix("")).replace("/", ".").replace("\\", ".")
406
+ return f"{module}.{var_name}"
407
+
408
+ def _extract_string_kwarg(self, source: str, key: str) -> str:
409
+ """Extract a string keyword argument from source code."""
410
+ import re
411
+
412
+ pattern = rf'{key}\s*=\s*["\']([^"\']*)["\']'
413
+ match = re.search(pattern, source)
414
+ return match.group(1) if match else ""
415
+
416
+ def _extract_list_kwarg(self, source: str, key: str) -> list[str]:
417
+ """Extract a list keyword argument from source code."""
418
+ import re
419
+
420
+ pattern = rf"{key}\s*=\s*\[([^\]]*)\]"
421
+ match = re.search(pattern, source)
422
+ if not match:
423
+ return []
424
+
425
+ items = match.group(1)
426
+ # Extract quoted strings
427
+ strings = re.findall(r'["\']([^"\']*)["\']', items)
428
+ return strings
429
+
430
+ def _extract_string_value(self, arg, file_path: Path | None = None) -> str:
431
+ """
432
+ Extract string value from a call argument.
433
+
434
+ Handles:
435
+ - Literal strings: "/api"
436
+ - Variable references: PREFIX (resolved via constant resolver)
437
+ - Attribute access: settings.PREFIX (resolved via constant resolver)
438
+ - Env vars with defaults: os.getenv("PREFIX", "/api") -> "/api"
439
+ """
440
+ # 1. Direct literal value
441
+ if arg.literal_value and isinstance(arg.literal_value, str):
442
+ return arg.literal_value
443
+
444
+ if arg.expression_text:
445
+ expr = arg.expression_text.strip()
446
+
447
+ # 2. Try to extract literal string from expression
448
+ string_match = re.search(r'^["\']([^"\']*)["\']$', expr)
449
+ if string_match:
450
+ return string_match.group(1)
451
+
452
+ # 3. Try constant resolver for variable/attribute access
453
+ if self._constant_resolver:
454
+ resolved = self._constant_resolver.resolve(expr, file_path)
455
+ if resolved:
456
+ return resolved.value
457
+
458
+ # 4. Try to extract default from os.getenv
459
+ env_match = re.search(
460
+ r'(?:os\.)?(?:getenv|environ\.get)\s*\(\s*["\'][^"\']+["\']\s*,\s*["\']([^"\']*)["\']',
461
+ expr,
462
+ )
463
+ if env_match:
464
+ return env_match.group(1)
465
+
466
+ # 5. Fallback: try to extract any string in the expression
467
+ fallback_match = re.search(r'["\']([^"\']*)["\']', expr)
468
+ if fallback_match:
469
+ return fallback_match.group(1)
470
+
471
+ # 6. Try variable name resolution
472
+ if arg.variable_name and self._constant_resolver:
473
+ resolved = self._constant_resolver.resolve(arg.variable_name, file_path)
474
+ if resolved:
475
+ return resolved.value
476
+
477
+ return ""
478
+
479
+ def _extract_list_value(self, arg) -> list[str]:
480
+ """Extract list value from a call argument.
481
+
482
+ Handles three representations:
483
+ 1. Actual Python list in literal_value
484
+ 2. String-encoded list in literal_value (e.g. ``'[Depends(func)]'``)
485
+ 3. expression_text containing a list expression
486
+ """
487
+ # Real Python list
488
+ if isinstance(arg.literal_value, list):
489
+ return [str(v) for v in arg.literal_value]
490
+
491
+ # Pick the best raw text to parse
492
+ raw = ""
493
+ if isinstance(arg.literal_value, str) and arg.literal_value.strip().startswith("["):
494
+ raw = arg.literal_value
495
+ elif arg.expression_text and str(arg.expression_text) not in ("None", ""):
496
+ raw = str(arg.expression_text)
497
+
498
+ if raw:
499
+ # Quoted strings (tags, etc.)
500
+ strings = re.findall(r'["\']([^"\']*)["\']', raw)
501
+ if strings:
502
+ return strings
503
+ # Depends() expressions (dependencies lists)
504
+ depends = re.findall(r"Depends\s*\(\s*([\w.]+)\s*\)", raw)
505
+ if depends:
506
+ return [f"Depends({d})" for d in depends]
507
+
508
+ return []
509
+
510
+ # =========================================================================
511
+ # Tree Building
512
+ # =========================================================================
513
+
514
+ def build_tree(self) -> RouterTree:
515
+ """Build the complete router tree."""
516
+ tree = RouterTree(
517
+ routers=dict(self._routers),
518
+ inclusions=list(self._inclusions),
519
+ var_to_qualified=dict(self._var_to_qualified),
520
+ )
521
+
522
+ # Deferred resolution: re-resolve inclusions whose qualified name
523
+ # couldn't be determined during process_file() because the target
524
+ # router's file hadn't been processed yet.
525
+ for inclusion in self._inclusions:
526
+ if inclusion.included_router_qualified is None:
527
+ inclusion.included_router_qualified = self._resolve_included_router(
528
+ inclusion.included_router, inclusion.file_path
529
+ )
530
+
531
+ # Create nodes for all routers
532
+ nodes: dict[str, RouterNode] = {}
533
+ for qualified, router_def in self._routers.items():
534
+ nodes[qualified] = RouterNode(
535
+ definition=router_def,
536
+ computed_prefix=router_def.prefix,
537
+ computed_tags=list(router_def.tags),
538
+ )
539
+
540
+ # Index inclusions by child for the propagation pass
541
+ inclusions_by_child: dict[str, RouterInclusion] = {}
542
+
543
+ # Resolve inclusions and build parent-child relationships
544
+ for inclusion in self._inclusions:
545
+ parent_qualified = self._resolve_parent(inclusion)
546
+ child_qualified = inclusion.included_router_qualified
547
+
548
+ if not parent_qualified or not child_qualified:
549
+ continue
550
+
551
+ parent_node = nodes.get(parent_qualified)
552
+ child_node = nodes.get(child_qualified)
553
+
554
+ if not parent_node or not child_node:
555
+ continue
556
+
557
+ # Set up relationship
558
+ child_node.parent = parent_node
559
+ parent_node.children.append(child_node)
560
+
561
+ # Compute full prefix
562
+ child_node.computed_prefix = self._join_paths(
563
+ parent_node.computed_prefix,
564
+ inclusion.prefix,
565
+ child_node.definition.prefix,
566
+ )
567
+
568
+ # Merge tags
569
+ child_node.computed_tags = (
570
+ parent_node.computed_tags + inclusion.tags + child_node.definition.tags
571
+ )
572
+
573
+ # Propagate dependencies — parent's accumulated deps + this
574
+ # inclusion's own deps flow to all routes under the child.
575
+ inclusion_dep_names = self._parse_depends_names(inclusion.dependencies)
576
+ child_node.computed_dependencies = (
577
+ list(parent_node.computed_dependencies) + inclusion_dep_names
578
+ )
579
+
580
+ inclusion.resolved = True
581
+ inclusions_by_child[child_qualified] = inclusion
582
+
583
+ # Find root nodes (no parent)
584
+ for node in nodes.values():
585
+ if node.parent is None:
586
+ tree.roots.append(node)
587
+
588
+ # Propagate prefixes and dependencies down the tree. The initial
589
+ # assignment above only uses the parent's state at processing time,
590
+ # which may be incomplete for deeply nested trees. A top-down pass
591
+ # from each root ensures correct accumulation.
592
+ for root in tree.roots:
593
+ self._propagate_tree(root, inclusions_by_child)
594
+
595
+ self._tree = tree
596
+ return tree
597
+
598
+ def _resolve_parent(self, inclusion: RouterInclusion) -> str | None:
599
+ """Resolve the parent router's qualified name."""
600
+ file_path = inclusion.file_path
601
+ parent_var = inclusion.parent_var
602
+
603
+ # Check local mappings first
604
+ if file_path in self._var_to_qualified:
605
+ if parent_var in self._var_to_qualified[file_path]:
606
+ return self._var_to_qualified[file_path][parent_var]
607
+
608
+ # Try as qualified name directly
609
+ if parent_var in self._routers:
610
+ return parent_var
611
+
612
+ return None
613
+
614
+ def _join_paths(self, *parts: str) -> str:
615
+ """Join path parts, handling slashes correctly."""
616
+ result = ""
617
+ for part in parts:
618
+ if not part:
619
+ continue
620
+ part = part.strip("/")
621
+ if not part:
622
+ continue
623
+ result = f"{result}/{part}" if result else part
624
+ return f"/{result}" if result else "/"
625
+
626
+ @staticmethod
627
+ def _parse_depends_names(raw_deps: list[str]) -> list[str]:
628
+ """Extract function names from ``Depends(func)`` expressions.
629
+
630
+ Handles raw strings like ``"Depends(get_current_user)"`` as well as
631
+ plain function names.
632
+ """
633
+ names: list[str] = []
634
+ for dep in raw_deps:
635
+ match = re.match(r"Depends\s*\(\s*(\w[\w.]*)\s*\)", dep)
636
+ if match:
637
+ names.append(match.group(1))
638
+ elif dep and re.match(r"^[\w.]+$", dep):
639
+ names.append(dep)
640
+ return names
641
+
642
+ def _propagate_tree(
643
+ self,
644
+ node: RouterNode,
645
+ inclusions_by_child: dict[str, RouterInclusion],
646
+ ) -> None:
647
+ """Recursively recompute prefixes and dependencies top-down.
648
+
649
+ This ensures deeply nested routers get the full accumulated state
650
+ from all ancestors, regardless of the order inclusions were processed.
651
+ """
652
+ for child in node.children:
653
+ child_q = child.definition.qualified_name
654
+ inclusion = inclusions_by_child.get(child_q)
655
+
656
+ # Recompute prefix from the (now-stable) parent
657
+ child.computed_prefix = self._join_paths(
658
+ node.computed_prefix,
659
+ inclusion.prefix if inclusion else "",
660
+ child.definition.prefix,
661
+ )
662
+
663
+ # Recompute tags
664
+ child.computed_tags = (
665
+ list(node.computed_tags)
666
+ + (inclusion.tags if inclusion else [])
667
+ + list(child.definition.tags)
668
+ )
669
+
670
+ # Recompute dependencies: parent's accumulated + inclusion-level + constructor-level
671
+ inclusion_dep_names = (
672
+ self._parse_depends_names(inclusion.dependencies) if inclusion else []
673
+ )
674
+ constructor_dep_names = self._parse_depends_names(
675
+ child.definition.constructor_dependencies
676
+ )
677
+ child.computed_dependencies = (
678
+ list(node.computed_dependencies) + inclusion_dep_names + constructor_dep_names
679
+ )
680
+
681
+ self._propagate_tree(child, inclusions_by_child)
682
+
683
+ # =========================================================================
684
+ # Query Methods
685
+ # =========================================================================
686
+
687
+ def resolve_path(
688
+ self,
689
+ router_var: str,
690
+ route_path: str,
691
+ file_path: Path,
692
+ ) -> str:
693
+ """
694
+ Resolve a route path to its final URL.
695
+
696
+ Args:
697
+ router_var: The router variable name (e.g., "router", "app")
698
+ route_path: The path from the decorator (e.g., "/users")
699
+ file_path: The file where the route is defined
700
+
701
+ Returns:
702
+ The fully resolved path with all prefixes applied
703
+ """
704
+ if not self._tree:
705
+ self.build_tree()
706
+
707
+ # Find the router
708
+ qualified = None
709
+ if file_path in self._var_to_qualified:
710
+ qualified = self._var_to_qualified[file_path].get(router_var)
711
+
712
+ if not qualified:
713
+ # Return path as-is
714
+ return route_path
715
+
716
+ # Find the node
717
+ for root in self._tree.roots:
718
+ node = self._find_node(root, qualified)
719
+ if node:
720
+ return self._join_paths(node.computed_prefix, route_path)
721
+
722
+ return route_path
723
+
724
+ def _find_node(self, node: RouterNode, qualified: str) -> RouterNode | None:
725
+ """Find a node by qualified name."""
726
+ if node.definition.qualified_name == qualified:
727
+ return node
728
+
729
+ for child in node.children:
730
+ found = self._find_node(child, qualified)
731
+ if found:
732
+ return found
733
+
734
+ return None
735
+
736
+ def get_router_dependencies(
737
+ self,
738
+ router_var: str,
739
+ file_path: Path,
740
+ ) -> list[str]:
741
+ """Return the accumulated dependency names for a router.
742
+
743
+ These are the dependency function names declared via
744
+ ``dependencies=[Depends(...)]`` on ``include_router()`` calls in the
745
+ ancestry chain. They apply to every route under this router.
746
+ """
747
+ if not self._tree:
748
+ self.build_tree()
749
+
750
+ qualified = None
751
+ if file_path in self._var_to_qualified:
752
+ qualified = self._var_to_qualified[file_path].get(router_var)
753
+
754
+ if not qualified:
755
+ return []
756
+
757
+ for root in self._tree.roots:
758
+ node = self._find_node(root, qualified)
759
+ if node:
760
+ return list(node.computed_dependencies)
761
+
762
+ return []
763
+
764
+ def get_router_by_var(self, var_name: str, file_path: Path) -> RouterDefinition | None:
765
+ """Get router definition by variable name in a file."""
766
+ if file_path in self._var_to_qualified:
767
+ qualified = self._var_to_qualified[file_path].get(var_name)
768
+ if qualified:
769
+ return self._routers.get(qualified)
770
+ return None
771
+
772
+ def get_all_routers(self) -> dict[str, RouterDefinition]:
773
+ """Get all registered routers."""
774
+ return dict(self._routers)
775
+
776
+ def get_root_apps(self) -> list[RouterDefinition]:
777
+ """Get all root FastAPI/Starlette applications."""
778
+ return [r for r in self._routers.values() if r.router_type in {"FastAPI", "Starlette"}]
779
+
780
+
781
+ # =============================================================================
782
+ # Convenience Functions
783
+ # =============================================================================
784
+
785
+
786
+ def build_router_registry(
787
+ parsed_files: list[ParsedFile],
788
+ project_root: Path | None = None,
789
+ ) -> RouterRegistry:
790
+ """
791
+ Build a router registry from parsed files.
792
+
793
+ Args:
794
+ parsed_files: List of successfully parsed files
795
+ project_root: Optional project root for path resolution
796
+
797
+ Returns:
798
+ Configured RouterRegistry with tree built
799
+ """
800
+ registry = RouterRegistry(project_root)
801
+
802
+ for parsed in parsed_files:
803
+ registry.process_file(parsed)
804
+
805
+ registry.build_tree()
806
+ return registry