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.
- apisec_code_bolt/__init__.py +42 -0
- apisec_code_bolt/__main__.py +11 -0
- apisec_code_bolt/analysis/__init__.py +96 -0
- apisec_code_bolt/analysis/analyzer.py +2309 -0
- apisec_code_bolt/analysis/binding_tracker.py +341 -0
- apisec_code_bolt/analysis/call_graph.py +1197 -0
- apisec_code_bolt/analysis/call_graph_types.py +332 -0
- apisec_code_bolt/analysis/call_resolver.py +988 -0
- apisec_code_bolt/analysis/capability_tagger.py +322 -0
- apisec_code_bolt/analysis/config_scanner.py +197 -0
- apisec_code_bolt/analysis/data_flow.py +1883 -0
- apisec_code_bolt/analysis/dependency_extractor.py +959 -0
- apisec_code_bolt/analysis/flow_analysis.py +1406 -0
- apisec_code_bolt/analysis/hof_catalog.py +61 -0
- apisec_code_bolt/analysis/integration_detector.py +1399 -0
- apisec_code_bolt/analysis/literal_scanner.py +300 -0
- apisec_code_bolt/analysis/path_normalizer.py +55 -0
- apisec_code_bolt/analysis/read_site_detector.py +310 -0
- apisec_code_bolt/analysis/request_patterns.py +162 -0
- apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
- apisec_code_bolt/analysis/sink_evidence.py +333 -0
- apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
- apisec_code_bolt/cli/__init__.py +5 -0
- apisec_code_bolt/cli/exit_codes.py +17 -0
- apisec_code_bolt/cli/main.py +1069 -0
- apisec_code_bolt/cloud/__init__.py +1 -0
- apisec_code_bolt/cloud/apisec_client.py +118 -0
- apisec_code_bolt/cloud/client.py +255 -0
- apisec_code_bolt/core/__init__.py +75 -0
- apisec_code_bolt/core/config.py +528 -0
- apisec_code_bolt/core/credentials.py +65 -0
- apisec_code_bolt/core/discovery.py +433 -0
- apisec_code_bolt/core/log_format.py +115 -0
- apisec_code_bolt/core/manifest.py +1009 -0
- apisec_code_bolt/core/repo.py +280 -0
- apisec_code_bolt/core/state.py +59 -0
- apisec_code_bolt/core/telemetry.py +451 -0
- apisec_code_bolt/core/types.py +587 -0
- apisec_code_bolt/fingerprinting/__init__.py +1 -0
- apisec_code_bolt/frameworks/__init__.py +29 -0
- apisec_code_bolt/frameworks/_jwt_common.py +50 -0
- apisec_code_bolt/frameworks/auth_helpers.py +437 -0
- apisec_code_bolt/frameworks/base.py +608 -0
- apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
- apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
- apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
- apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
- apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
- apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
- apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
- apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
- apisec_code_bolt/frameworks/java/__init__.py +6 -0
- apisec_code_bolt/frameworks/java/_annotations.py +167 -0
- apisec_code_bolt/frameworks/java/_constraints.py +128 -0
- apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
- apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
- apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
- apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
- apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
- apisec_code_bolt/frameworks/js/__init__.py +8 -0
- apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
- apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
- apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
- apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
- apisec_code_bolt/frameworks/python/__init__.py +19 -0
- apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
- apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
- apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
- apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
- apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
- apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
- apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
- apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
- apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
- apisec_code_bolt/parsing/__init__.py +62 -0
- apisec_code_bolt/parsing/base.py +554 -0
- apisec_code_bolt/parsing/csharp/__init__.py +5 -0
- apisec_code_bolt/parsing/csharp/language_services.py +203 -0
- apisec_code_bolt/parsing/csharp/literals.py +72 -0
- apisec_code_bolt/parsing/csharp/parser.py +1158 -0
- apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
- apisec_code_bolt/parsing/js/__init__.py +5 -0
- apisec_code_bolt/parsing/js/language_services.py +118 -0
- apisec_code_bolt/parsing/js/parser.py +622 -0
- apisec_code_bolt/parsing/jvm/__init__.py +7 -0
- apisec_code_bolt/parsing/jvm/language_services.py +270 -0
- apisec_code_bolt/parsing/jvm/parser.py +774 -0
- apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
- apisec_code_bolt/parsing/python/__init__.py +150 -0
- apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
- apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
- apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
- apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
- apisec_code_bolt/parsing/python/expression_utils.py +221 -0
- apisec_code_bolt/parsing/python/extraction_types.py +271 -0
- apisec_code_bolt/parsing/python/language_services.py +487 -0
- apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
- apisec_code_bolt/parsing/python/parser.py +719 -0
- apisec_code_bolt/parsing/python/path_resolver.py +576 -0
- apisec_code_bolt/parsing/python/router_registry.py +806 -0
- apisec_code_bolt/parsing/python/type_resolver.py +730 -0
- apisec_code_bolt/parsing/python/visitors.py +1544 -0
- apisec_code_bolt/parsing/services.py +544 -0
- apisec_code_bolt/query/__init__.py +1 -0
- apisec_code_bolt/query/ast_cache.py +182 -0
- apisec_code_bolt/query/executor.py +283 -0
- apisec_code_bolt/query/handlers.py +832 -0
- apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
- apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
- apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
- apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type and schema resolution for Python code analysis.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
- Resolving type annotations to their definitions
|
|
6
|
+
- Resolving Pydantic model schemas including nested models
|
|
7
|
+
- Building type dependency graphs
|
|
8
|
+
- Resolving imports to their source definitions
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .visitors import ExtractedClass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# =============================================================================
|
|
23
|
+
# Resolved Type Information
|
|
24
|
+
# =============================================================================
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ResolvedType:
|
|
29
|
+
"""A resolved type with full information."""
|
|
30
|
+
|
|
31
|
+
name: str # Simple name
|
|
32
|
+
qualified_name: str # Full qualified name
|
|
33
|
+
module: str # Module where defined
|
|
34
|
+
|
|
35
|
+
# Type category
|
|
36
|
+
is_builtin: bool = False
|
|
37
|
+
is_generic: bool = False
|
|
38
|
+
is_optional: bool = False
|
|
39
|
+
is_list: bool = False
|
|
40
|
+
is_dict: bool = False
|
|
41
|
+
is_union: bool = False
|
|
42
|
+
is_pydantic_model: bool = False
|
|
43
|
+
is_dataclass: bool = False
|
|
44
|
+
is_enum: bool = False
|
|
45
|
+
|
|
46
|
+
# Generic type arguments
|
|
47
|
+
type_args: list[ResolvedType] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
# For complex types
|
|
50
|
+
origin_type: str | None = None # e.g., "list" for list[int]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ResolvedField:
|
|
55
|
+
"""A resolved field in a model."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
type_annotation: str
|
|
59
|
+
resolved_type: ResolvedType | None = None
|
|
60
|
+
|
|
61
|
+
required: bool = True
|
|
62
|
+
default: Any = None
|
|
63
|
+
default_factory: str | None = None
|
|
64
|
+
|
|
65
|
+
# Field constraints
|
|
66
|
+
constraints: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
# Nested model reference
|
|
69
|
+
nested_model: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ResolvedModel:
|
|
74
|
+
"""A fully resolved Pydantic model or dataclass."""
|
|
75
|
+
|
|
76
|
+
name: str
|
|
77
|
+
qualified_name: str
|
|
78
|
+
module: str
|
|
79
|
+
|
|
80
|
+
fields: list[ResolvedField] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
# Inheritance
|
|
83
|
+
base_classes: list[str] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
# Nested models (models used as field types)
|
|
86
|
+
nested_models: list[str] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
# Model configuration
|
|
89
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
is_pydantic: bool = False
|
|
92
|
+
is_dataclass: bool = False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# =============================================================================
|
|
96
|
+
# Built-in Types
|
|
97
|
+
# =============================================================================
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
BUILTIN_TYPES = frozenset(
|
|
101
|
+
{
|
|
102
|
+
"int",
|
|
103
|
+
"str",
|
|
104
|
+
"float",
|
|
105
|
+
"bool",
|
|
106
|
+
"bytes",
|
|
107
|
+
"None",
|
|
108
|
+
"list",
|
|
109
|
+
"dict",
|
|
110
|
+
"set",
|
|
111
|
+
"frozenset",
|
|
112
|
+
"tuple",
|
|
113
|
+
"List",
|
|
114
|
+
"Dict",
|
|
115
|
+
"Set",
|
|
116
|
+
"FrozenSet",
|
|
117
|
+
"Tuple",
|
|
118
|
+
"Optional",
|
|
119
|
+
"Union",
|
|
120
|
+
"Any",
|
|
121
|
+
"Type",
|
|
122
|
+
"Callable",
|
|
123
|
+
"Sequence",
|
|
124
|
+
"Mapping",
|
|
125
|
+
"Iterable",
|
|
126
|
+
"Iterator",
|
|
127
|
+
"Literal",
|
|
128
|
+
"Final",
|
|
129
|
+
"ClassVar",
|
|
130
|
+
"TypeVar",
|
|
131
|
+
"Annotated",
|
|
132
|
+
"Generic",
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
TYPING_MODULE_TYPES = frozenset(
|
|
138
|
+
{
|
|
139
|
+
"List",
|
|
140
|
+
"Dict",
|
|
141
|
+
"Set",
|
|
142
|
+
"FrozenSet",
|
|
143
|
+
"Tuple",
|
|
144
|
+
"Optional",
|
|
145
|
+
"Union",
|
|
146
|
+
"Any",
|
|
147
|
+
"Type",
|
|
148
|
+
"Callable",
|
|
149
|
+
"Sequence",
|
|
150
|
+
"Mapping",
|
|
151
|
+
"Iterable",
|
|
152
|
+
"Iterator",
|
|
153
|
+
"Literal",
|
|
154
|
+
"Final",
|
|
155
|
+
"ClassVar",
|
|
156
|
+
"TypeVar",
|
|
157
|
+
"Annotated",
|
|
158
|
+
"Generic",
|
|
159
|
+
"Protocol",
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# =============================================================================
|
|
165
|
+
# Type Annotation Parser
|
|
166
|
+
# =============================================================================
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TypeAnnotationParser:
|
|
170
|
+
"""
|
|
171
|
+
Parses type annotation strings into structured types.
|
|
172
|
+
|
|
173
|
+
Handles:
|
|
174
|
+
- Simple types: int, str, MyClass
|
|
175
|
+
- Generic types: list[int], dict[str, int]
|
|
176
|
+
- Union types: int | str, Union[int, str]
|
|
177
|
+
- Optional types: Optional[int], int | None
|
|
178
|
+
- Nested generics: list[dict[str, list[int]]]
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
# Pattern for generic types: TypeName[Args]
|
|
182
|
+
GENERIC_PATTERN = re.compile(r"^(\w+(?:\.\w+)*)\s*\[(.*)\]$", re.DOTALL)
|
|
183
|
+
|
|
184
|
+
# Pattern for union types using |
|
|
185
|
+
UNION_PATTERN = re.compile(r"\s*\|\s*")
|
|
186
|
+
|
|
187
|
+
def parse(self, annotation: str) -> ResolvedType:
|
|
188
|
+
"""Parse a type annotation string."""
|
|
189
|
+
annotation = annotation.strip()
|
|
190
|
+
|
|
191
|
+
# Handle None
|
|
192
|
+
if annotation == "None":
|
|
193
|
+
return ResolvedType(
|
|
194
|
+
name="None",
|
|
195
|
+
qualified_name="None",
|
|
196
|
+
module="builtins",
|
|
197
|
+
is_builtin=True,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Handle | union syntax
|
|
201
|
+
if " | " in annotation or annotation.startswith("Union["):
|
|
202
|
+
return self._parse_union(annotation)
|
|
203
|
+
|
|
204
|
+
# Handle Optional
|
|
205
|
+
if annotation.startswith("Optional["):
|
|
206
|
+
return self._parse_optional(annotation)
|
|
207
|
+
|
|
208
|
+
# Handle generic types
|
|
209
|
+
generic_match = self.GENERIC_PATTERN.match(annotation)
|
|
210
|
+
if generic_match:
|
|
211
|
+
return self._parse_generic(generic_match.group(1), generic_match.group(2))
|
|
212
|
+
|
|
213
|
+
# Simple type
|
|
214
|
+
return self._create_simple_type(annotation)
|
|
215
|
+
|
|
216
|
+
def _parse_union(self, annotation: str) -> ResolvedType:
|
|
217
|
+
"""Parse a union type."""
|
|
218
|
+
# Extract types from Union[...] or A | B syntax
|
|
219
|
+
if annotation.startswith("Union["):
|
|
220
|
+
inner = annotation[6:-1]
|
|
221
|
+
type_strs = self._split_type_args(inner)
|
|
222
|
+
else:
|
|
223
|
+
type_strs = self.UNION_PATTERN.split(annotation)
|
|
224
|
+
|
|
225
|
+
type_args = [self.parse(t.strip()) for t in type_strs]
|
|
226
|
+
|
|
227
|
+
# Check if this is really Optional (Union with None)
|
|
228
|
+
has_none = any(t.name == "None" for t in type_args)
|
|
229
|
+
non_none_types = [t for t in type_args if t.name != "None"]
|
|
230
|
+
|
|
231
|
+
if has_none and len(non_none_types) == 1:
|
|
232
|
+
# This is Optional[X]
|
|
233
|
+
result = non_none_types[0]
|
|
234
|
+
result.is_optional = True
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
return ResolvedType(
|
|
238
|
+
name="Union",
|
|
239
|
+
qualified_name="typing.Union",
|
|
240
|
+
module="typing",
|
|
241
|
+
is_union=True,
|
|
242
|
+
is_generic=True,
|
|
243
|
+
type_args=type_args,
|
|
244
|
+
origin_type="Union",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def _parse_optional(self, annotation: str) -> ResolvedType:
|
|
248
|
+
"""Parse Optional[X] type."""
|
|
249
|
+
inner = annotation[9:-1] # Remove "Optional[" and "]"
|
|
250
|
+
inner_type = self.parse(inner)
|
|
251
|
+
inner_type.is_optional = True
|
|
252
|
+
return inner_type
|
|
253
|
+
|
|
254
|
+
def _parse_generic(self, base_type: str, args_str: str) -> ResolvedType:
|
|
255
|
+
"""Parse a generic type like list[int] or dict[str, int]."""
|
|
256
|
+
# Parse type arguments
|
|
257
|
+
arg_strs = self._split_type_args(args_str)
|
|
258
|
+
type_args = [self.parse(arg.strip()) for arg in arg_strs]
|
|
259
|
+
|
|
260
|
+
# Determine type properties
|
|
261
|
+
base_lower = base_type.lower()
|
|
262
|
+
is_list = base_lower in {"list", "sequence", "iterable"}
|
|
263
|
+
is_dict = base_lower in {"dict", "mapping"}
|
|
264
|
+
is_builtin = base_type in BUILTIN_TYPES or base_type in TYPING_MODULE_TYPES
|
|
265
|
+
|
|
266
|
+
module = "builtins" if base_lower in {"list", "dict", "set", "tuple"} else "typing"
|
|
267
|
+
if not is_builtin:
|
|
268
|
+
module = "" # User-defined type
|
|
269
|
+
|
|
270
|
+
return ResolvedType(
|
|
271
|
+
name=base_type,
|
|
272
|
+
qualified_name=f"{module}.{base_type}" if module else base_type,
|
|
273
|
+
module=module,
|
|
274
|
+
is_builtin=is_builtin,
|
|
275
|
+
is_generic=True,
|
|
276
|
+
is_list=is_list,
|
|
277
|
+
is_dict=is_dict,
|
|
278
|
+
type_args=type_args,
|
|
279
|
+
origin_type=base_type,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def _create_simple_type(self, type_name: str) -> ResolvedType:
|
|
283
|
+
"""Create a simple (non-generic) type."""
|
|
284
|
+
is_builtin = type_name in BUILTIN_TYPES
|
|
285
|
+
module = "builtins" if is_builtin else ""
|
|
286
|
+
|
|
287
|
+
return ResolvedType(
|
|
288
|
+
name=type_name,
|
|
289
|
+
qualified_name=f"{module}.{type_name}" if module else type_name,
|
|
290
|
+
module=module,
|
|
291
|
+
is_builtin=is_builtin,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _split_type_args(self, args_str: str) -> list[str]:
|
|
295
|
+
"""
|
|
296
|
+
Split type arguments respecting nested brackets.
|
|
297
|
+
|
|
298
|
+
e.g., "str, list[int], dict[str, int]" -> ["str", "list[int]", "dict[str, int]"]
|
|
299
|
+
"""
|
|
300
|
+
result = []
|
|
301
|
+
current = []
|
|
302
|
+
depth = 0
|
|
303
|
+
|
|
304
|
+
for char in args_str:
|
|
305
|
+
if char == "[":
|
|
306
|
+
depth += 1
|
|
307
|
+
current.append(char)
|
|
308
|
+
elif char == "]":
|
|
309
|
+
depth -= 1
|
|
310
|
+
current.append(char)
|
|
311
|
+
elif char == "," and depth == 0:
|
|
312
|
+
result.append("".join(current).strip())
|
|
313
|
+
current = []
|
|
314
|
+
else:
|
|
315
|
+
current.append(char)
|
|
316
|
+
|
|
317
|
+
if current:
|
|
318
|
+
result.append("".join(current).strip())
|
|
319
|
+
|
|
320
|
+
return [r for r in result if r]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# =============================================================================
|
|
324
|
+
# Type Resolver
|
|
325
|
+
# =============================================================================
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class TypeResolver:
|
|
329
|
+
"""
|
|
330
|
+
Resolves type references to their definitions.
|
|
331
|
+
|
|
332
|
+
Maintains:
|
|
333
|
+
- Import mapping (name -> module)
|
|
334
|
+
- Class definitions (name -> ExtractedClass)
|
|
335
|
+
- Resolved type cache
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def __init__(self) -> None:
|
|
339
|
+
self._parser = TypeAnnotationParser()
|
|
340
|
+
|
|
341
|
+
# Import tracking
|
|
342
|
+
self._imports: dict[str, str] = {} # name -> module
|
|
343
|
+
self._from_imports: dict[str, tuple[str, str]] = {} # name -> (module, original_name)
|
|
344
|
+
|
|
345
|
+
# Class definitions
|
|
346
|
+
self._classes: dict[str, ExtractedClass] = {} # qualified_name -> class
|
|
347
|
+
|
|
348
|
+
# File to module mapping
|
|
349
|
+
self._file_modules: dict[Path, str] = {}
|
|
350
|
+
|
|
351
|
+
# Resolution cache
|
|
352
|
+
self._cache: dict[str, ResolvedType] = {}
|
|
353
|
+
|
|
354
|
+
def add_import(self, imp) -> None:
|
|
355
|
+
"""
|
|
356
|
+
Register an import for resolution.
|
|
357
|
+
|
|
358
|
+
Handles both ExtractedImport (names is list of tuples) and
|
|
359
|
+
ParsedImport (names is list of strings with separate alias field).
|
|
360
|
+
"""
|
|
361
|
+
# Handle different import formats
|
|
362
|
+
names_list = imp.names
|
|
363
|
+
|
|
364
|
+
if imp.is_from_import:
|
|
365
|
+
for item in names_list:
|
|
366
|
+
# Handle tuple format (name, alias) from ExtractedImport
|
|
367
|
+
if isinstance(item, tuple):
|
|
368
|
+
name, alias = item
|
|
369
|
+
key = alias or name
|
|
370
|
+
self._from_imports[key] = (imp.module, name)
|
|
371
|
+
else:
|
|
372
|
+
# Handle string format from ParsedImport
|
|
373
|
+
name = item
|
|
374
|
+
key = name
|
|
375
|
+
self._from_imports[key] = (imp.module, name)
|
|
376
|
+
else:
|
|
377
|
+
for item in names_list:
|
|
378
|
+
if isinstance(item, tuple):
|
|
379
|
+
name, alias = item
|
|
380
|
+
key = alias or name
|
|
381
|
+
self._imports[key] = name
|
|
382
|
+
else:
|
|
383
|
+
name = item
|
|
384
|
+
# For regular import, the alias is stored separately
|
|
385
|
+
alias = getattr(imp, "alias", None)
|
|
386
|
+
key = alias or name
|
|
387
|
+
self._imports[key] = name
|
|
388
|
+
|
|
389
|
+
def add_class(self, cls: ExtractedClass, file_path: Path | None = None) -> None:
|
|
390
|
+
"""Register a class definition."""
|
|
391
|
+
self._classes[cls.qualified_name] = cls
|
|
392
|
+
self._classes[cls.name] = cls # Also by simple name
|
|
393
|
+
|
|
394
|
+
def set_file_module(self, file_path: Path, module_name: str) -> None:
|
|
395
|
+
"""Set module name for a file."""
|
|
396
|
+
self._file_modules[file_path] = module_name
|
|
397
|
+
|
|
398
|
+
def resolve_type(self, annotation: str) -> ResolvedType:
|
|
399
|
+
"""Resolve a type annotation to full type information."""
|
|
400
|
+
if annotation in self._cache:
|
|
401
|
+
return self._cache[annotation]
|
|
402
|
+
|
|
403
|
+
resolved = self._parser.parse(annotation)
|
|
404
|
+
|
|
405
|
+
# Resolve user-defined types
|
|
406
|
+
if not resolved.is_builtin and not resolved.is_generic:
|
|
407
|
+
self._resolve_user_type(resolved)
|
|
408
|
+
|
|
409
|
+
# Recursively resolve type arguments
|
|
410
|
+
for i, arg in enumerate(resolved.type_args):
|
|
411
|
+
if not arg.is_builtin:
|
|
412
|
+
resolved.type_args[i] = self.resolve_type(arg.name)
|
|
413
|
+
|
|
414
|
+
self._cache[annotation] = resolved
|
|
415
|
+
return resolved
|
|
416
|
+
|
|
417
|
+
def _resolve_user_type(self, resolved: ResolvedType) -> None:
|
|
418
|
+
"""Resolve a user-defined type to its definition."""
|
|
419
|
+
name = resolved.name
|
|
420
|
+
|
|
421
|
+
# Check from imports
|
|
422
|
+
if name in self._from_imports:
|
|
423
|
+
module, original_name = self._from_imports[name]
|
|
424
|
+
resolved.module = module
|
|
425
|
+
resolved.qualified_name = f"{module}.{original_name}"
|
|
426
|
+
|
|
427
|
+
# Check regular imports
|
|
428
|
+
elif name in self._imports:
|
|
429
|
+
resolved.module = self._imports[name]
|
|
430
|
+
resolved.qualified_name = self._imports[name]
|
|
431
|
+
|
|
432
|
+
# Check registered classes
|
|
433
|
+
if name in self._classes:
|
|
434
|
+
cls = self._classes[name]
|
|
435
|
+
resolved.is_pydantic_model = cls.is_pydantic_model
|
|
436
|
+
resolved.is_dataclass = cls.is_dataclass
|
|
437
|
+
resolved.qualified_name = cls.qualified_name
|
|
438
|
+
|
|
439
|
+
def resolve_model(self, cls: ExtractedClass) -> ResolvedModel:
|
|
440
|
+
"""Fully resolve a Pydantic model or dataclass."""
|
|
441
|
+
resolved_fields: list[ResolvedField] = []
|
|
442
|
+
nested_models: list[str] = []
|
|
443
|
+
|
|
444
|
+
for f in cls.fields:
|
|
445
|
+
resolved_field = ResolvedField(
|
|
446
|
+
name=f.name,
|
|
447
|
+
type_annotation=f.annotation or "Any",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Resolve the field type
|
|
451
|
+
if f.annotation:
|
|
452
|
+
resolved_type = self.resolve_type(f.annotation)
|
|
453
|
+
resolved_field.resolved_type = resolved_type
|
|
454
|
+
|
|
455
|
+
# Check for nested models
|
|
456
|
+
nested = self._find_nested_models(resolved_type)
|
|
457
|
+
nested_models.extend(nested)
|
|
458
|
+
if nested:
|
|
459
|
+
resolved_field.nested_model = nested[0]
|
|
460
|
+
|
|
461
|
+
# Parse field constraints from default
|
|
462
|
+
if f.default:
|
|
463
|
+
self._parse_field_constraints(resolved_field, f.default, f.field_info)
|
|
464
|
+
else:
|
|
465
|
+
resolved_field.required = True
|
|
466
|
+
|
|
467
|
+
resolved_fields.append(resolved_field)
|
|
468
|
+
|
|
469
|
+
return ResolvedModel(
|
|
470
|
+
name=cls.name,
|
|
471
|
+
qualified_name=cls.qualified_name,
|
|
472
|
+
module=cls.qualified_name.rsplit(".", 1)[0] if "." in cls.qualified_name else "",
|
|
473
|
+
fields=resolved_fields,
|
|
474
|
+
base_classes=cls.bases,
|
|
475
|
+
nested_models=list(set(nested_models)),
|
|
476
|
+
is_pydantic=cls.is_pydantic_model,
|
|
477
|
+
is_dataclass=cls.is_dataclass,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def _find_nested_models(self, resolved_type: ResolvedType) -> list[str]:
|
|
481
|
+
"""Find nested model references in a type."""
|
|
482
|
+
nested: list[str] = []
|
|
483
|
+
|
|
484
|
+
# Check if this type itself is a model
|
|
485
|
+
if resolved_type.is_pydantic_model or resolved_type.is_dataclass:
|
|
486
|
+
nested.append(resolved_type.qualified_name or resolved_type.name)
|
|
487
|
+
|
|
488
|
+
# Check type arguments recursively
|
|
489
|
+
for arg in resolved_type.type_args:
|
|
490
|
+
nested.extend(self._find_nested_models(arg))
|
|
491
|
+
|
|
492
|
+
return nested
|
|
493
|
+
|
|
494
|
+
def _parse_field_constraints(
|
|
495
|
+
self,
|
|
496
|
+
field: ResolvedField,
|
|
497
|
+
default_value: str,
|
|
498
|
+
field_info: dict[str, Any],
|
|
499
|
+
) -> None:
|
|
500
|
+
"""Parse field constraints from default value and Field() info."""
|
|
501
|
+
# Check if it's a Field() call
|
|
502
|
+
if default_value.startswith("Field("):
|
|
503
|
+
field.required = "..." in default_value or "default" not in field_info
|
|
504
|
+
|
|
505
|
+
# Copy constraints from field_info
|
|
506
|
+
for key, value in field_info.items():
|
|
507
|
+
if key in {"default", "default_factory"}:
|
|
508
|
+
if key == "default":
|
|
509
|
+
field.default = value
|
|
510
|
+
field.required = False
|
|
511
|
+
else:
|
|
512
|
+
field.default_factory = value
|
|
513
|
+
field.required = False
|
|
514
|
+
else:
|
|
515
|
+
field.constraints[key] = value
|
|
516
|
+
elif default_value == "...":
|
|
517
|
+
field.required = True
|
|
518
|
+
elif default_value != "":
|
|
519
|
+
field.required = False
|
|
520
|
+
field.default = default_value
|
|
521
|
+
|
|
522
|
+
def get_all_nested_models(self, model_name: str) -> list[ResolvedModel]:
|
|
523
|
+
"""
|
|
524
|
+
Get all nested models recursively for a given model.
|
|
525
|
+
|
|
526
|
+
Returns list of all models that are referenced by the given model,
|
|
527
|
+
including transitively nested models.
|
|
528
|
+
"""
|
|
529
|
+
visited: set[str] = set()
|
|
530
|
+
result: list[ResolvedModel] = []
|
|
531
|
+
|
|
532
|
+
self._collect_nested_models(model_name, visited, result)
|
|
533
|
+
|
|
534
|
+
return result
|
|
535
|
+
|
|
536
|
+
def _collect_nested_models(
|
|
537
|
+
self,
|
|
538
|
+
model_name: str,
|
|
539
|
+
visited: set[str],
|
|
540
|
+
result: list[ResolvedModel],
|
|
541
|
+
) -> None:
|
|
542
|
+
"""Recursively collect nested models."""
|
|
543
|
+
if model_name in visited:
|
|
544
|
+
return
|
|
545
|
+
visited.add(model_name)
|
|
546
|
+
|
|
547
|
+
# Find the class
|
|
548
|
+
cls = self._classes.get(model_name)
|
|
549
|
+
if not cls:
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
# Resolve the model
|
|
553
|
+
resolved = self.resolve_model(cls)
|
|
554
|
+
result.append(resolved)
|
|
555
|
+
|
|
556
|
+
# Recursively collect nested models
|
|
557
|
+
for nested_name in resolved.nested_models:
|
|
558
|
+
self._collect_nested_models(nested_name, visited, result)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# =============================================================================
|
|
562
|
+
# Schema Builder
|
|
563
|
+
# =============================================================================
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class SchemaBuilder:
|
|
567
|
+
"""
|
|
568
|
+
Builds complete schema representations for API request/response bodies.
|
|
569
|
+
|
|
570
|
+
Handles:
|
|
571
|
+
- Pydantic model schemas
|
|
572
|
+
- Nested model resolution
|
|
573
|
+
- Field type resolution
|
|
574
|
+
- Constraint extraction
|
|
575
|
+
"""
|
|
576
|
+
|
|
577
|
+
def __init__(self, resolver: TypeResolver) -> None:
|
|
578
|
+
self._resolver = resolver
|
|
579
|
+
|
|
580
|
+
def build_body_schema(
|
|
581
|
+
self,
|
|
582
|
+
model_name: str,
|
|
583
|
+
classes: dict[str, ExtractedClass],
|
|
584
|
+
) -> dict[str, Any]:
|
|
585
|
+
"""
|
|
586
|
+
Build a complete schema for a request/response body model.
|
|
587
|
+
|
|
588
|
+
Returns a JSON-schema-like dict representation.
|
|
589
|
+
"""
|
|
590
|
+
# Register all classes
|
|
591
|
+
for cls in classes.values():
|
|
592
|
+
self._resolver.add_class(cls)
|
|
593
|
+
|
|
594
|
+
# Check if model exists
|
|
595
|
+
if model_name not in classes:
|
|
596
|
+
return {"type": "unknown", "name": model_name}
|
|
597
|
+
|
|
598
|
+
cls = classes[model_name]
|
|
599
|
+
return self._build_model_schema(cls, set())
|
|
600
|
+
|
|
601
|
+
def _build_model_schema(
|
|
602
|
+
self,
|
|
603
|
+
cls: ExtractedClass,
|
|
604
|
+
visited: set[str],
|
|
605
|
+
) -> dict[str, Any]:
|
|
606
|
+
"""Build schema for a single model."""
|
|
607
|
+
if cls.qualified_name in visited:
|
|
608
|
+
return {"$ref": cls.qualified_name}
|
|
609
|
+
visited.add(cls.qualified_name)
|
|
610
|
+
|
|
611
|
+
schema: dict[str, Any] = {
|
|
612
|
+
"type": "object",
|
|
613
|
+
"name": cls.name,
|
|
614
|
+
"qualified_name": cls.qualified_name,
|
|
615
|
+
"properties": {},
|
|
616
|
+
"required": [],
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
for f in cls.fields:
|
|
620
|
+
field_schema = self._build_field_schema(f, visited)
|
|
621
|
+
schema["properties"][f.name] = field_schema
|
|
622
|
+
|
|
623
|
+
# Check if required
|
|
624
|
+
if f.default is None and not f.field_info.get("default"):
|
|
625
|
+
schema["required"].append(f.name)
|
|
626
|
+
|
|
627
|
+
return schema
|
|
628
|
+
|
|
629
|
+
def _build_field_schema(
|
|
630
|
+
self,
|
|
631
|
+
f: ExtractedField, # noqa: F821
|
|
632
|
+
visited: set[str],
|
|
633
|
+
) -> dict[str, Any]:
|
|
634
|
+
"""Build schema for a single field."""
|
|
635
|
+
field_schema: dict[str, Any] = {}
|
|
636
|
+
|
|
637
|
+
if f.annotation:
|
|
638
|
+
resolved = self._resolver.resolve_type(f.annotation)
|
|
639
|
+
field_schema = self._type_to_schema(resolved, visited)
|
|
640
|
+
|
|
641
|
+
# Add constraints
|
|
642
|
+
if f.field_info:
|
|
643
|
+
for key, value in f.field_info.items():
|
|
644
|
+
if key not in {"default", "default_factory"}:
|
|
645
|
+
field_schema[key] = value
|
|
646
|
+
|
|
647
|
+
# Add default
|
|
648
|
+
if f.default:
|
|
649
|
+
field_schema["default"] = f.default
|
|
650
|
+
|
|
651
|
+
return field_schema
|
|
652
|
+
|
|
653
|
+
def _type_to_schema(
|
|
654
|
+
self,
|
|
655
|
+
resolved: ResolvedType,
|
|
656
|
+
visited: set[str],
|
|
657
|
+
) -> dict[str, Any]:
|
|
658
|
+
"""Convert a resolved type to JSON-schema representation."""
|
|
659
|
+
# Handle builtins
|
|
660
|
+
if resolved.is_builtin:
|
|
661
|
+
return self._builtin_to_schema(resolved)
|
|
662
|
+
|
|
663
|
+
# Handle Optional
|
|
664
|
+
if resolved.is_optional:
|
|
665
|
+
inner_schema = self._type_to_schema(
|
|
666
|
+
resolved.type_args[0] if resolved.type_args else resolved,
|
|
667
|
+
visited,
|
|
668
|
+
)
|
|
669
|
+
return {"anyOf": [inner_schema, {"type": "null"}]}
|
|
670
|
+
|
|
671
|
+
# Handle Union
|
|
672
|
+
if resolved.is_union:
|
|
673
|
+
return {"anyOf": [self._type_to_schema(arg, visited) for arg in resolved.type_args]}
|
|
674
|
+
|
|
675
|
+
# Handle List
|
|
676
|
+
if resolved.is_list and resolved.type_args:
|
|
677
|
+
return {
|
|
678
|
+
"type": "array",
|
|
679
|
+
"items": self._type_to_schema(resolved.type_args[0], visited),
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
# Handle Dict
|
|
683
|
+
if resolved.is_dict and len(resolved.type_args) >= 2:
|
|
684
|
+
return {
|
|
685
|
+
"type": "object",
|
|
686
|
+
"additionalProperties": self._type_to_schema(resolved.type_args[1], visited),
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
# Handle user-defined models
|
|
690
|
+
if resolved.is_pydantic_model or resolved.is_dataclass:
|
|
691
|
+
# Check if we have the class definition
|
|
692
|
+
cls_name = resolved.qualified_name or resolved.name
|
|
693
|
+
if cls_name in self._resolver._classes:
|
|
694
|
+
cls = self._resolver._classes[cls_name]
|
|
695
|
+
return self._build_model_schema(cls, visited)
|
|
696
|
+
return {"$ref": cls_name}
|
|
697
|
+
|
|
698
|
+
# Unknown type
|
|
699
|
+
return {"type": "unknown", "name": resolved.name}
|
|
700
|
+
|
|
701
|
+
def _builtin_to_schema(self, resolved: ResolvedType) -> dict[str, Any]:
|
|
702
|
+
"""Convert builtin type to JSON schema."""
|
|
703
|
+
type_mapping = {
|
|
704
|
+
"str": {"type": "string"},
|
|
705
|
+
"int": {"type": "integer"},
|
|
706
|
+
"float": {"type": "number"},
|
|
707
|
+
"bool": {"type": "boolean"},
|
|
708
|
+
"None": {"type": "null"},
|
|
709
|
+
"bytes": {"type": "string", "format": "binary"},
|
|
710
|
+
"Any": {},
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
name = resolved.name.lower()
|
|
714
|
+
if name in type_mapping:
|
|
715
|
+
return type_mapping[name]
|
|
716
|
+
|
|
717
|
+
# Handle generic builtins
|
|
718
|
+
if resolved.is_list:
|
|
719
|
+
items = self._type_to_schema(resolved.type_args[0], set()) if resolved.type_args else {}
|
|
720
|
+
return {"type": "array", "items": items}
|
|
721
|
+
|
|
722
|
+
if resolved.is_dict:
|
|
723
|
+
additional = (
|
|
724
|
+
self._type_to_schema(resolved.type_args[1], set())
|
|
725
|
+
if len(resolved.type_args) > 1
|
|
726
|
+
else {}
|
|
727
|
+
)
|
|
728
|
+
return {"type": "object", "additionalProperties": additional}
|
|
729
|
+
|
|
730
|
+
return {"type": resolved.name}
|