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,789 @@
1
+ """
2
+ Parameter analysis for Python function signatures.
3
+
4
+ This module provides robust analysis of function parameters to determine:
5
+ - Parameter location (path, query, body, header, cookie)
6
+ - Type information and constraints
7
+ - Default value semantics
8
+ - Dependency injection patterns
9
+
10
+ CRITICAL: This replaces fragile string-based detection with proper CST analysis.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ import libcst as cst
20
+
21
+ if TYPE_CHECKING:
22
+ from ..base import ParsedParameter
23
+
24
+
25
+ # =============================================================================
26
+ # Parameter Analysis Results
27
+ # =============================================================================
28
+
29
+
30
+ @dataclass
31
+ class AnalyzedParameter:
32
+ """
33
+ Result of analyzing a function parameter.
34
+
35
+ Contains semantic understanding of what the parameter represents
36
+ in the context of an HTTP handler.
37
+ """
38
+
39
+ name: str
40
+
41
+ # Determined location
42
+ location: (
43
+ str # "path", "query", "body", "header", "cookie", "form", "file", "dependency", "unknown"
44
+ )
45
+ location_confidence: float # 0.0 to 1.0
46
+
47
+ # Type information
48
+ type_annotation: str | None = None
49
+ base_type: str | None = None # Unwrapped from Optional, Annotated, etc.
50
+ is_optional: bool = False
51
+ is_list: bool = False
52
+
53
+ # Default value analysis
54
+ has_default: bool = False
55
+ default_value: str | None = None
56
+ default_is_dependency: bool = False # Depends(...)
57
+ default_is_marker: bool = False # Query(...), Path(...), etc.
58
+
59
+ # Marker details (when default_is_marker=True)
60
+ marker_type: str | None = None # "Query", "Path", "Body", etc.
61
+ marker_args: dict[str, Any] = field(default_factory=dict)
62
+
63
+ # Dependency details (when default_is_dependency=True)
64
+ dependency_function: str | None = None
65
+
66
+ # Validation constraints extracted from marker
67
+ constraints: dict[str, Any] = field(default_factory=dict)
68
+
69
+ # Analysis notes
70
+ notes: list[str] = field(default_factory=list)
71
+
72
+
73
+ @dataclass
74
+ class RoutePathAnalysis:
75
+ """Analysis of a route path for parameter extraction."""
76
+
77
+ path: str
78
+ path_params: list[str] = field(default_factory=list) # Names of {param} in path
79
+ has_path_params: bool = False
80
+
81
+
82
+ # =============================================================================
83
+ # Constants
84
+ # =============================================================================
85
+
86
+
87
+ # FastAPI/Starlette parameter marker functions
88
+ FASTAPI_MARKERS = {
89
+ "Path": "path",
90
+ "Query": "query",
91
+ "Header": "header",
92
+ "Cookie": "cookie",
93
+ "Body": "body",
94
+ "Form": "form",
95
+ "File": "body", # Files are body content
96
+ "UploadFile": "body",
97
+ }
98
+
99
+ # Import aliases we should recognize
100
+ FASTAPI_MARKER_MODULES = frozenset(
101
+ {
102
+ "fastapi",
103
+ "fastapi.param_functions",
104
+ "starlette",
105
+ }
106
+ )
107
+
108
+ # Dependency injection markers
109
+ DEPENDENCY_MARKERS = frozenset({"Depends", "Security"})
110
+
111
+ # Known model base classes (for body detection)
112
+ BODY_MODEL_BASES = frozenset(
113
+ {
114
+ "BaseModel",
115
+ "BaseSettings",
116
+ "pydantic.BaseModel",
117
+ "pydantic.BaseSettings",
118
+ }
119
+ )
120
+
121
+ # Special parameters to skip
122
+ SKIP_PARAMS = frozenset({"self", "cls", "request", "response", "websocket", "background_tasks"})
123
+
124
+
125
+ # =============================================================================
126
+ # Path Parameter Extractor
127
+ # =============================================================================
128
+
129
+
130
+ def extract_path_params(route_path: str) -> RoutePathAnalysis:
131
+ """
132
+ Extract path parameters from a route path.
133
+
134
+ Handles:
135
+ - Simple params: /users/{user_id}
136
+ - Typed params: /users/{user_id:int}
137
+ - Multiple params: /users/{user_id}/posts/{post_id}
138
+ - Path params: /files/{file_path:path}
139
+
140
+ Args:
141
+ route_path: The route path string (e.g., "/users/{user_id}")
142
+
143
+ Returns:
144
+ RoutePathAnalysis with extracted parameter names
145
+ """
146
+ # Pattern to match {param} or {param:type}
147
+ pattern = r"\{([^}:]+)(?::[^}]+)?\}"
148
+
149
+ matches = re.findall(pattern, route_path)
150
+
151
+ return RoutePathAnalysis(
152
+ path=route_path,
153
+ path_params=matches,
154
+ has_path_params=len(matches) > 0,
155
+ )
156
+
157
+
158
+ # =============================================================================
159
+ # Default Value Analyzer
160
+ # =============================================================================
161
+
162
+
163
+ class DefaultValueAnalyzer:
164
+ """
165
+ Analyzes default value expressions to determine parameter semantics.
166
+
167
+ This parses the default value AST to properly identify:
168
+ - Depends(func) patterns
169
+ - Query(...), Path(...), etc. markers
170
+ - Literal defaults
171
+ - Complex expressions
172
+ """
173
+
174
+ def __init__(self, import_aliases: dict[str, str] | None = None):
175
+ """
176
+ Initialize analyzer with import context.
177
+
178
+ Args:
179
+ import_aliases: Mapping of local names to their original imports.
180
+ e.g., {"Q": "Query"} if `from fastapi import Query as Q`
181
+ """
182
+ self._aliases = import_aliases or {}
183
+
184
+ def analyze(self, default_value: str | None) -> dict[str, Any]:
185
+ """
186
+ Analyze a default value string.
187
+
188
+ Args:
189
+ default_value: The default value source code string
190
+
191
+ Returns:
192
+ Dict with analysis results:
193
+ - is_marker: bool
194
+ - marker_type: str | None
195
+ - marker_args: dict
196
+ - is_dependency: bool
197
+ - dependency_function: str | None
198
+ - is_literal: bool
199
+ - literal_value: Any
200
+ """
201
+ if not default_value:
202
+ return {"is_marker": False, "is_dependency": False, "is_literal": False}
203
+
204
+ default_value = default_value.strip()
205
+
206
+ # Try to parse as CST
207
+ try:
208
+ expr = cst.parse_expression(default_value)
209
+ return self._analyze_expression(expr)
210
+ except Exception:
211
+ # Fallback to pattern matching
212
+ return self._analyze_fallback(default_value)
213
+
214
+ def _analyze_expression(self, expr: cst.BaseExpression) -> dict[str, Any]:
215
+ """Analyze a parsed CST expression."""
216
+ result = {
217
+ "is_marker": False,
218
+ "marker_type": None,
219
+ "marker_args": {},
220
+ "is_dependency": False,
221
+ "dependency_function": None,
222
+ "is_literal": False,
223
+ "literal_value": None,
224
+ }
225
+
226
+ # Check if it's a function call
227
+ if isinstance(expr, cst.Call):
228
+ func_name = self._get_call_name(expr.func)
229
+
230
+ # Resolve alias
231
+ resolved_name = self._aliases.get(func_name, func_name)
232
+
233
+ # Check for dependency markers
234
+ if resolved_name in DEPENDENCY_MARKERS:
235
+ result["is_dependency"] = True
236
+ # Extract dependency function from first argument
237
+ if expr.args:
238
+ first_arg = expr.args[0]
239
+ result["dependency_function"] = self._expr_to_string(first_arg.value)
240
+
241
+ # Check for FastAPI parameter markers
242
+ elif resolved_name in FASTAPI_MARKERS:
243
+ result["is_marker"] = True
244
+ result["marker_type"] = resolved_name
245
+ result["marker_args"] = self._extract_call_kwargs(expr)
246
+
247
+ # Check for common aliases
248
+ elif func_name.split(".")[-1] in FASTAPI_MARKERS:
249
+ actual_name = func_name.split(".")[-1]
250
+ result["is_marker"] = True
251
+ result["marker_type"] = actual_name
252
+ result["marker_args"] = self._extract_call_kwargs(expr)
253
+
254
+ # Check for literals
255
+ elif isinstance(expr, (cst.Integer, cst.Float, cst.SimpleString)):
256
+ result["is_literal"] = True
257
+ result["literal_value"] = self._extract_literal_value(expr)
258
+
259
+ elif isinstance(expr, cst.Name):
260
+ if expr.value in {"None", "True", "False"}:
261
+ result["is_literal"] = True
262
+ result["literal_value"] = {"None": None, "True": True, "False": False}.get(
263
+ expr.value
264
+ )
265
+
266
+ return result
267
+
268
+ def _get_call_name(self, func: cst.BaseExpression) -> str:
269
+ """Get the name of a called function."""
270
+ if isinstance(func, cst.Name):
271
+ return func.value
272
+ elif isinstance(func, cst.Attribute):
273
+ # Handle chained attributes like fastapi.Query
274
+ parts = []
275
+ current = func
276
+ while isinstance(current, cst.Attribute):
277
+ parts.append(current.attr.value)
278
+ current = current.value
279
+ if isinstance(current, cst.Name):
280
+ parts.append(current.value)
281
+ parts.reverse()
282
+ return ".".join(parts)
283
+ return ""
284
+
285
+ def _extract_call_kwargs(self, call: cst.Call) -> dict[str, Any]:
286
+ """Extract keyword arguments from a call."""
287
+ kwargs = {}
288
+
289
+ for i, arg in enumerate(call.args):
290
+ if arg.keyword:
291
+ key = arg.keyword.value
292
+ kwargs[key] = self._expr_to_string(arg.value)
293
+ elif i == 0 and isinstance(arg.value, (cst.SimpleString, cst.ConcatenatedString)):
294
+ # First positional arg might be description
295
+ kwargs["_positional_0"] = self._extract_string_value(arg.value)
296
+
297
+ return kwargs
298
+
299
+ def _expr_to_string(self, expr: cst.BaseExpression) -> str:
300
+ """Convert expression back to source string."""
301
+ try:
302
+ module = cst.parse_module("")
303
+ return module.code_for_node(expr)
304
+ except Exception:
305
+ return str(expr)
306
+
307
+ def _extract_literal_value(self, node: cst.BaseExpression) -> Any:
308
+ """Extract literal value from a node."""
309
+ if isinstance(node, cst.Integer):
310
+ return int(node.value)
311
+ elif isinstance(node, cst.Float):
312
+ return float(node.value)
313
+ elif isinstance(node, (cst.SimpleString, cst.ConcatenatedString)):
314
+ return self._extract_string_value(node)
315
+ return None
316
+
317
+ def _extract_string_value(self, node: cst.BaseExpression) -> str:
318
+ """Extract actual string value from string node."""
319
+ if isinstance(node, cst.SimpleString):
320
+ raw = node.value
321
+ # Remove quotes and prefixes
322
+ for prefix in ["r", "b", "f", "u", "fr", "rf", "br", "rb"]:
323
+ if raw.lower().startswith(prefix):
324
+ raw = raw[len(prefix) :]
325
+ break
326
+ if raw.startswith('"""') or raw.startswith("'''"):
327
+ return raw[3:-3]
328
+ elif raw.startswith('"') or raw.startswith("'"):
329
+ return raw[1:-1]
330
+ return str(node)
331
+
332
+ def _analyze_fallback(self, default_value: str) -> dict[str, Any]:
333
+ """Fallback analysis using pattern matching."""
334
+ result = {
335
+ "is_marker": False,
336
+ "marker_type": None,
337
+ "marker_args": {},
338
+ "is_dependency": False,
339
+ "dependency_function": None,
340
+ "is_literal": False,
341
+ "literal_value": None,
342
+ }
343
+
344
+ # Check for dependency markers (Depends / Security), including
345
+ # qualified callables like Depends(auth.get_current_user).
346
+ dep_match = re.match(r"^(?:Depends|Security)\s*\(\s*([\w.]+)\s*\)", default_value)
347
+ if dep_match:
348
+ result["is_dependency"] = True
349
+ result["dependency_function"] = dep_match.group(1)
350
+ return result
351
+
352
+ # Check for marker patterns
353
+ for marker in FASTAPI_MARKERS:
354
+ pattern = rf"^{marker}\s*\("
355
+ if re.match(pattern, default_value):
356
+ result["is_marker"] = True
357
+ result["marker_type"] = marker
358
+ return result
359
+
360
+ # Check for simple literals
361
+ if default_value in {"None", "True", "False"}:
362
+ result["is_literal"] = True
363
+ result["literal_value"] = {"None": None, "True": True, "False": False}.get(
364
+ default_value
365
+ )
366
+ elif default_value.startswith(('"', "'")) and default_value.endswith(('"', "'")):
367
+ result["is_literal"] = True
368
+ result["literal_value"] = default_value[1:-1]
369
+ elif default_value.isdigit():
370
+ result["is_literal"] = True
371
+ result["literal_value"] = int(default_value)
372
+
373
+ return result
374
+
375
+
376
+ # =============================================================================
377
+ # Type Annotation Analyzer
378
+ # =============================================================================
379
+
380
+
381
+ class TypeAnnotationAnalyzer:
382
+ """
383
+ Analyzes type annotations to extract semantic information.
384
+
385
+ Handles:
386
+ - Optional[X] -> X with is_optional=True
387
+ - Annotated[X, ...] -> X with markers extracted
388
+ - list[X] -> X with is_list=True
389
+ - Union[X, None] -> X with is_optional=True
390
+ - Type aliases (e.g. SessionDep = Annotated[Session, Depends(get_db)])
391
+ """
392
+
393
+ def __init__(
394
+ self,
395
+ known_models: set[str] | None = None,
396
+ type_aliases: dict[str, str] | None = None,
397
+ ):
398
+ """
399
+ Initialize with known model names and type alias mappings.
400
+
401
+ Args:
402
+ known_models: Set of known Pydantic model names
403
+ type_aliases: Mapping of alias name to its expanded RHS text,
404
+ e.g. ``{"SessionDep": "Annotated[Session, Depends(get_db)]"}``
405
+ """
406
+ self._known_models = known_models or set()
407
+ self._type_aliases = type_aliases or {}
408
+
409
+ def add_known_model(self, name: str) -> None:
410
+ """Register a known model name."""
411
+ self._known_models.add(name)
412
+
413
+ def _expand_aliases(self, annotation: str) -> str:
414
+ """Expand known type aliases in *annotation* (single-hop)."""
415
+ stripped = annotation.strip()
416
+ if stripped in self._type_aliases:
417
+ return self._type_aliases[stripped]
418
+ for wrapper in ("Optional[", "Union["):
419
+ if stripped.startswith(wrapper) and stripped.endswith("]"):
420
+ inner = stripped[len(wrapper) : -1].strip()
421
+ parts = self._split_type_args(inner)
422
+ changed = False
423
+ new_parts = []
424
+ for p in parts:
425
+ p_stripped = p.strip()
426
+ if p_stripped in self._type_aliases:
427
+ new_parts.append(self._type_aliases[p_stripped])
428
+ changed = True
429
+ else:
430
+ new_parts.append(p)
431
+ if changed:
432
+ return f"{wrapper}{', '.join(new_parts)}]"
433
+ return annotation
434
+
435
+ def analyze(self, annotation: str | None) -> dict[str, Any]:
436
+ """
437
+ Analyze a type annotation string.
438
+
439
+ Returns:
440
+ Dict with:
441
+ - base_type: str | None - The innermost type
442
+ - is_optional: bool
443
+ - is_list: bool
444
+ - is_model: bool - Whether base_type is a known Pydantic model
445
+ - annotated_markers: list - Markers from Annotated[X, marker1, marker2]
446
+ """
447
+ if not annotation:
448
+ return {
449
+ "base_type": None,
450
+ "is_optional": False,
451
+ "is_list": False,
452
+ "is_model": False,
453
+ "annotated_markers": [],
454
+ }
455
+
456
+ annotation = self._expand_aliases(annotation.strip())
457
+
458
+ # Track properties as we unwrap
459
+ is_optional = False
460
+ is_list = False
461
+ annotated_markers = []
462
+
463
+ # Unwrap layers
464
+ unwrapped = annotation
465
+
466
+ # Handle Optional[X] or X | None
467
+ if unwrapped.startswith("Optional[") and unwrapped.endswith("]"):
468
+ is_optional = True
469
+ unwrapped = unwrapped[9:-1].strip()
470
+ elif " | None" in unwrapped:
471
+ is_optional = True
472
+ unwrapped = unwrapped.replace(" | None", "").replace("None | ", "").strip()
473
+ elif unwrapped.startswith("Union[") and ", None]" in unwrapped:
474
+ is_optional = True
475
+ # Extract non-None type from Union
476
+ inner = unwrapped[6:-1]
477
+ types = self._split_type_args(inner)
478
+ non_none = [t for t in types if t.strip() != "None"]
479
+ if len(non_none) == 1:
480
+ unwrapped = non_none[0].strip()
481
+
482
+ # Handle Annotated[X, ...]
483
+ if unwrapped.startswith("Annotated[") and unwrapped.endswith("]"):
484
+ inner = unwrapped[10:-1]
485
+ parts = self._split_type_args(inner)
486
+ if parts:
487
+ unwrapped = parts[0].strip()
488
+ annotated_markers = [p.strip() for p in parts[1:]]
489
+
490
+ # Handle list[X], List[X], Sequence[X]
491
+ list_prefixes = ["list[", "List[", "Sequence[", "typing.List["]
492
+ for prefix in list_prefixes:
493
+ if unwrapped.startswith(prefix) and unwrapped.endswith("]"):
494
+ is_list = True
495
+ unwrapped = unwrapped[len(prefix) : -1].strip()
496
+ break
497
+
498
+ # Check if it's a known model
499
+ is_model = unwrapped in self._known_models
500
+
501
+ return {
502
+ "base_type": unwrapped,
503
+ "is_optional": is_optional,
504
+ "is_list": is_list,
505
+ "is_model": is_model,
506
+ "annotated_markers": annotated_markers,
507
+ }
508
+
509
+ def _split_type_args(self, args_str: str) -> list[str]:
510
+ """Split type arguments respecting nested brackets."""
511
+ result = []
512
+ current = []
513
+ depth = 0
514
+
515
+ for char in args_str:
516
+ if char == "[":
517
+ depth += 1
518
+ current.append(char)
519
+ elif char == "]":
520
+ depth -= 1
521
+ current.append(char)
522
+ elif char == "," and depth == 0:
523
+ result.append("".join(current))
524
+ current = []
525
+ else:
526
+ current.append(char)
527
+
528
+ if current:
529
+ result.append("".join(current))
530
+
531
+ return [r.strip() for r in result if r.strip()]
532
+
533
+
534
+ # =============================================================================
535
+ # Parameter Analyzer
536
+ # =============================================================================
537
+
538
+
539
+ class ParameterAnalyzer:
540
+ """
541
+ Comprehensive parameter analyzer for HTTP handler functions.
542
+
543
+ Combines:
544
+ - Path parameter extraction from route
545
+ - Type annotation analysis
546
+ - Default value analysis
547
+ - Known model detection
548
+ - Import alias resolution
549
+
550
+ This is the main entry point for parameter analysis.
551
+ """
552
+
553
+ def __init__(
554
+ self,
555
+ import_aliases: dict[str, str] | None = None,
556
+ known_models: set[str] | None = None,
557
+ type_aliases: dict[str, str] | None = None,
558
+ ):
559
+ """
560
+ Initialize the analyzer.
561
+
562
+ Args:
563
+ import_aliases: Mapping of local import names to original names
564
+ known_models: Set of known Pydantic model names
565
+ type_aliases: Mapping of type alias names to their expanded RHS text
566
+ """
567
+ self._import_aliases = import_aliases or {}
568
+ self._known_models = known_models or set()
569
+ self._type_aliases = type_aliases or {}
570
+
571
+ self._default_analyzer = DefaultValueAnalyzer(self._import_aliases)
572
+ self._type_analyzer = TypeAnnotationAnalyzer(self._known_models, self._type_aliases)
573
+
574
+ def add_import_alias(self, local_name: str, original_name: str) -> None:
575
+ """Add an import alias mapping."""
576
+ self._import_aliases[local_name] = original_name
577
+ self._default_analyzer = DefaultValueAnalyzer(self._import_aliases)
578
+
579
+ def add_known_model(self, name: str) -> None:
580
+ """Register a known Pydantic model."""
581
+ self._known_models.add(name)
582
+ self._type_analyzer.add_known_model(name)
583
+
584
+ def analyze_function_params(
585
+ self,
586
+ params: list[ParsedParameter],
587
+ route_path: str | None = None,
588
+ ) -> list[AnalyzedParameter]:
589
+ """
590
+ Analyze all parameters of a function.
591
+
592
+ Args:
593
+ params: List of ParsedParameter from the function
594
+ route_path: Optional route path for path parameter detection
595
+
596
+ Returns:
597
+ List of AnalyzedParameter with full semantic analysis
598
+ """
599
+ # Extract path parameters from route
600
+ path_params: set[str] = set()
601
+ if route_path:
602
+ path_analysis = extract_path_params(route_path)
603
+ path_params = set(path_analysis.path_params)
604
+
605
+ results = []
606
+
607
+ for param in params:
608
+ # Skip special parameters
609
+ if param.name in SKIP_PARAMS:
610
+ continue
611
+
612
+ analyzed = self._analyze_single_param(param, path_params)
613
+ results.append(analyzed)
614
+
615
+ return results
616
+
617
+ def _analyze_single_param(
618
+ self,
619
+ param: ParsedParameter,
620
+ path_params: set[str],
621
+ ) -> AnalyzedParameter:
622
+ """Analyze a single parameter."""
623
+ # Start with analysis results
624
+ result = AnalyzedParameter(
625
+ name=param.name,
626
+ location="unknown",
627
+ location_confidence=0.0,
628
+ type_annotation=param.type_annotation,
629
+ has_default=param.default_value is not None,
630
+ default_value=param.default_value,
631
+ )
632
+
633
+ # Analyze type annotation
634
+ type_info = self._type_analyzer.analyze(param.type_annotation)
635
+ result.base_type = type_info["base_type"]
636
+ result.is_optional = type_info["is_optional"]
637
+ result.is_list = type_info["is_list"]
638
+
639
+ # Check for Annotated markers (e.g. Annotated[Session, Depends(get_db)])
640
+ annotated_markers = type_info.get("annotated_markers", [])
641
+ for marker in annotated_markers:
642
+ marker_info = self._default_analyzer.analyze(marker)
643
+ if marker_info.get("is_dependency"):
644
+ result.default_is_dependency = True
645
+ result.dependency_function = marker_info.get("dependency_function")
646
+ result.location = "dependency"
647
+ result.location_confidence = 1.0
648
+ return result
649
+ if marker_info.get("is_marker"):
650
+ result.default_is_marker = True
651
+ result.marker_type = marker_info.get("marker_type")
652
+ result.marker_args = marker_info.get("marker_args", {})
653
+ break
654
+
655
+ # Analyze default value
656
+ default_info = self._default_analyzer.analyze(param.default_value)
657
+
658
+ if default_info["is_dependency"]:
659
+ result.default_is_dependency = True
660
+ result.dependency_function = default_info.get("dependency_function")
661
+ result.location = "dependency"
662
+ result.location_confidence = 1.0
663
+ return result
664
+
665
+ if default_info["is_marker"]:
666
+ result.default_is_marker = True
667
+ result.marker_type = default_info.get("marker_type")
668
+ result.marker_args = default_info.get("marker_args", {})
669
+
670
+ # Extract constraints from marker args
671
+ for key in ["min_length", "max_length", "gt", "ge", "lt", "le", "regex", "pattern"]:
672
+ if key in result.marker_args:
673
+ result.constraints[key] = result.marker_args[key]
674
+
675
+ # Determine location with precedence rules
676
+ location, confidence = self._determine_location(
677
+ param_name=param.name,
678
+ path_params=path_params,
679
+ marker_type=result.marker_type,
680
+ base_type=result.base_type,
681
+ is_model=type_info["is_model"],
682
+ has_default=result.has_default,
683
+ default_value=param.default_value,
684
+ )
685
+
686
+ result.location = location
687
+ result.location_confidence = confidence
688
+
689
+ return result
690
+
691
+ def _determine_location(
692
+ self,
693
+ param_name: str,
694
+ path_params: set[str],
695
+ marker_type: str | None,
696
+ base_type: str | None,
697
+ is_model: bool,
698
+ has_default: bool,
699
+ default_value: str | None,
700
+ ) -> tuple[str, float]:
701
+ """
702
+ Determine parameter location with confidence score.
703
+
704
+ Precedence (highest to lowest):
705
+ 1. Explicit marker (Query, Path, Header, etc.) - 1.0
706
+ 2. Path parameter match from route - 1.0
707
+ 3. Pydantic model type - 0.95
708
+ 4. Type-based inference - 0.7
709
+ 5. Name-based inference - 0.5
710
+ 6. Default: query - 0.3
711
+
712
+ Returns:
713
+ Tuple of (location, confidence)
714
+ """
715
+ # 1. Explicit marker takes highest precedence
716
+ if marker_type:
717
+ location = FASTAPI_MARKERS.get(marker_type, "query")
718
+ return (location, 1.0)
719
+
720
+ # 2. Path parameter from route
721
+ if param_name in path_params:
722
+ return ("path", 1.0)
723
+
724
+ # 3. Pydantic model = body
725
+ if is_model:
726
+ return ("body", 0.95)
727
+
728
+ # Check base type for special cases
729
+ if base_type:
730
+ base_lower = base_type.lower()
731
+
732
+ # 4. Type-based inference
733
+ if base_type in {"UploadFile", "bytes"} or "uploadfile" in base_lower:
734
+ return ("body", 0.9)
735
+
736
+ # Common body type names
737
+ if any(
738
+ pattern in base_lower
739
+ for pattern in ["request", "payload", "input", "create", "update"]
740
+ ):
741
+ if base_type[0].isupper(): # Looks like a class name
742
+ return ("body", 0.8)
743
+
744
+ # 5. Name-based inference (lower confidence)
745
+ name_lower = param_name.lower()
746
+
747
+ # Header patterns
748
+ if any(
749
+ pattern in name_lower for pattern in ["header", "x_", "authorization", "content_type"]
750
+ ):
751
+ return ("header", 0.6)
752
+
753
+ # Cookie patterns
754
+ if any(pattern in name_lower for pattern in ["cookie", "session_id", "csrf"]):
755
+ return ("cookie", 0.6)
756
+
757
+ # 6. Default: query parameters
758
+ # But with even lower confidence if no type annotation
759
+ if base_type:
760
+ return ("query", 0.5)
761
+ else:
762
+ return ("query", 0.3)
763
+
764
+
765
+ # =============================================================================
766
+ # Convenience Functions
767
+ # =============================================================================
768
+
769
+
770
+ def analyze_route_parameters(
771
+ params: list[ParsedParameter],
772
+ route_path: str,
773
+ import_aliases: dict[str, str] | None = None,
774
+ known_models: set[str] | None = None,
775
+ ) -> list[AnalyzedParameter]:
776
+ """
777
+ Convenience function to analyze route handler parameters.
778
+
779
+ Args:
780
+ params: Function parameters
781
+ route_path: The route path (e.g., "/users/{user_id}")
782
+ import_aliases: Import alias mappings
783
+ known_models: Known Pydantic model names
784
+
785
+ Returns:
786
+ List of analyzed parameters
787
+ """
788
+ analyzer = ParameterAnalyzer(import_aliases, known_models)
789
+ return analyzer.analyze_function_params(params, route_path)