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,500 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constant and configuration value resolver for Python projects.
|
|
3
|
+
|
|
4
|
+
This module handles resolving constants, config attributes, and settings
|
|
5
|
+
even when they CAN be overwritten by environment variables. The philosophy:
|
|
6
|
+
|
|
7
|
+
"Resolve what we can statically. Note that it may vary at runtime."
|
|
8
|
+
|
|
9
|
+
Handles:
|
|
10
|
+
1. Module-level constants: API_PREFIX = "/api"
|
|
11
|
+
2. Class attributes: class Config: PREFIX = "/api"
|
|
12
|
+
3. Pydantic Settings with defaults: class Settings(BaseSettings): prefix: str = "/api"
|
|
13
|
+
4. Attribute chains: config.api.prefix
|
|
14
|
+
5. Environment variable defaults: os.getenv("PREFIX", "/api") -> "/api"
|
|
15
|
+
|
|
16
|
+
CRITICAL: Enterprise apps often use configuration objects. Even if values CAN
|
|
17
|
+
be overridden, the default value is usually what's deployed in most environments.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from ..base import ParsedClass, ParsedFile
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# =============================================================================
|
|
32
|
+
# Data Types
|
|
33
|
+
# =============================================================================
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ResolvedConstant:
|
|
38
|
+
"""A resolved constant value."""
|
|
39
|
+
|
|
40
|
+
name: str # Full name (e.g., "config.api.PREFIX")
|
|
41
|
+
value: str # Resolved value
|
|
42
|
+
|
|
43
|
+
# Source information
|
|
44
|
+
source_file: Path | None = None
|
|
45
|
+
source_line: int = 0
|
|
46
|
+
source_type: str = "" # "module_var", "class_attr", "settings", "env_default"
|
|
47
|
+
|
|
48
|
+
# Confidence and notes
|
|
49
|
+
is_default: bool = True # True if this is a default that could be overridden
|
|
50
|
+
confidence: float = 1.0
|
|
51
|
+
override_sources: list[str] = field(default_factory=list) # e.g., ["ENV:API_PREFIX"]
|
|
52
|
+
|
|
53
|
+
# For documentation
|
|
54
|
+
description: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ConfigClass:
|
|
59
|
+
"""A configuration class definition."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
qualified_name: str
|
|
63
|
+
file_path: Path
|
|
64
|
+
|
|
65
|
+
# Is it a Pydantic Settings class?
|
|
66
|
+
is_pydantic_settings: bool = False
|
|
67
|
+
|
|
68
|
+
# Attributes with their default values
|
|
69
|
+
attributes: dict[str, ResolvedConstant] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
# For nested config: config.api.prefix
|
|
72
|
+
nested_configs: dict[str, str] = field(default_factory=dict) # attr_name -> class_name
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# =============================================================================
|
|
76
|
+
# Constant Resolver
|
|
77
|
+
# =============================================================================
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ConstantResolver:
|
|
81
|
+
"""
|
|
82
|
+
Resolves constants and configuration values across a project.
|
|
83
|
+
|
|
84
|
+
Strategy:
|
|
85
|
+
1. Scan all files for module-level constants (UPPER_CASE assignments)
|
|
86
|
+
2. Identify configuration classes (Settings, Config, etc.)
|
|
87
|
+
3. Extract default values from class attributes
|
|
88
|
+
4. Build an attribute chain resolver for nested access
|
|
89
|
+
5. Track which values can be overridden and how
|
|
90
|
+
|
|
91
|
+
Usage:
|
|
92
|
+
resolver = ConstantResolver()
|
|
93
|
+
|
|
94
|
+
for parsed in parsed_files:
|
|
95
|
+
resolver.process_file(parsed)
|
|
96
|
+
|
|
97
|
+
resolver.build_resolution_graph()
|
|
98
|
+
|
|
99
|
+
value = resolver.resolve("settings.API_PREFIX", file_path)
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, project_root: Path | None = None):
|
|
103
|
+
"""Initialize the resolver."""
|
|
104
|
+
self._project_root = project_root
|
|
105
|
+
|
|
106
|
+
# Module-level constants per file
|
|
107
|
+
self._module_constants: dict[Path, dict[str, ResolvedConstant]] = {}
|
|
108
|
+
|
|
109
|
+
# Configuration classes
|
|
110
|
+
self._config_classes: dict[str, ConfigClass] = {}
|
|
111
|
+
|
|
112
|
+
# Variable to class instance mapping per file
|
|
113
|
+
# e.g., {"settings": "app.config.Settings"}
|
|
114
|
+
self._instance_mapping: dict[Path, dict[str, str]] = {}
|
|
115
|
+
|
|
116
|
+
# Global constant lookup (name -> value)
|
|
117
|
+
self._global_lookup: dict[str, str] = {}
|
|
118
|
+
|
|
119
|
+
# Import mappings per file
|
|
120
|
+
self._imports: dict[Path, dict[str, str]] = {}
|
|
121
|
+
|
|
122
|
+
def process_file(self, parsed: ParsedFile) -> None:
|
|
123
|
+
"""Process a parsed file to extract constants."""
|
|
124
|
+
if not parsed.success:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
file_path = parsed.path
|
|
128
|
+
self._module_constants[file_path] = {}
|
|
129
|
+
self._instance_mapping[file_path] = {}
|
|
130
|
+
self._imports[file_path] = {}
|
|
131
|
+
|
|
132
|
+
# 1. Build import mappings
|
|
133
|
+
self._extract_imports(parsed)
|
|
134
|
+
|
|
135
|
+
# 2. Extract module-level constants
|
|
136
|
+
self._extract_module_constants(parsed)
|
|
137
|
+
|
|
138
|
+
# 3. Extract configuration classes
|
|
139
|
+
self._extract_config_classes(parsed)
|
|
140
|
+
|
|
141
|
+
# 4. Track config instance assignments
|
|
142
|
+
self._extract_config_instances(parsed)
|
|
143
|
+
|
|
144
|
+
def _extract_imports(self, parsed: ParsedFile) -> None:
|
|
145
|
+
"""Extract import mappings."""
|
|
146
|
+
file_path = parsed.path
|
|
147
|
+
|
|
148
|
+
for imp in parsed.imports:
|
|
149
|
+
if imp.is_from_import:
|
|
150
|
+
for name in imp.names:
|
|
151
|
+
local_name = imp.alias if len(imp.names) == 1 and imp.alias else name
|
|
152
|
+
self._imports[file_path][local_name] = f"{imp.module}.{name}"
|
|
153
|
+
else:
|
|
154
|
+
local_name = imp.alias or imp.module.split(".")[-1]
|
|
155
|
+
self._imports[file_path][local_name] = imp.module
|
|
156
|
+
|
|
157
|
+
def _extract_module_constants(self, parsed: ParsedFile) -> None:
|
|
158
|
+
"""Extract module-level constant assignments."""
|
|
159
|
+
file_path = parsed.path
|
|
160
|
+
|
|
161
|
+
for assign in parsed.assignments:
|
|
162
|
+
# Skip function-local assignments
|
|
163
|
+
if assign.in_function:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
name = assign.target
|
|
167
|
+
source = assign.value_source or ""
|
|
168
|
+
|
|
169
|
+
# Check for constant naming (UPPER_CASE or Title_Case with underscore)
|
|
170
|
+
if not (name.isupper() or (name[0].isupper() and "_" in name)):
|
|
171
|
+
# Also accept common config names
|
|
172
|
+
if name.lower() not in {
|
|
173
|
+
"prefix",
|
|
174
|
+
"api_prefix",
|
|
175
|
+
"base_path",
|
|
176
|
+
"root_path",
|
|
177
|
+
"version",
|
|
178
|
+
}:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
# Try to extract value
|
|
182
|
+
value, source_type, override_sources = self._extract_constant_value(source)
|
|
183
|
+
|
|
184
|
+
if value is not None:
|
|
185
|
+
const = ResolvedConstant(
|
|
186
|
+
name=name,
|
|
187
|
+
value=value,
|
|
188
|
+
source_file=file_path,
|
|
189
|
+
source_line=assign.location.line,
|
|
190
|
+
source_type=source_type,
|
|
191
|
+
is_default=len(override_sources) > 0,
|
|
192
|
+
override_sources=override_sources,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
self._module_constants[file_path][name] = const
|
|
196
|
+
self._global_lookup[name] = value
|
|
197
|
+
|
|
198
|
+
def _extract_constant_value(self, source: str) -> tuple[str | None, str, list[str]]:
|
|
199
|
+
"""
|
|
200
|
+
Extract constant value from source code.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Tuple of (value, source_type, override_sources)
|
|
204
|
+
"""
|
|
205
|
+
source = source.strip()
|
|
206
|
+
override_sources: list[str] = []
|
|
207
|
+
|
|
208
|
+
# 1. Simple string literal
|
|
209
|
+
string_match = re.match(r'^["\']([^"\']*)["\']$', source)
|
|
210
|
+
if string_match:
|
|
211
|
+
return string_match.group(1), "literal", []
|
|
212
|
+
|
|
213
|
+
# 2. os.getenv() or os.environ.get() with default
|
|
214
|
+
env_match = re.search(
|
|
215
|
+
r'(?:os\.)?(?:getenv|environ\.get)\s*\(\s*["\']([^"\']+)["\']\s*(?:,\s*["\']([^"\']*)["\'])?\s*\)',
|
|
216
|
+
source,
|
|
217
|
+
)
|
|
218
|
+
if env_match:
|
|
219
|
+
env_name = env_match.group(1)
|
|
220
|
+
default = env_match.group(2)
|
|
221
|
+
override_sources.append(f"ENV:{env_name}")
|
|
222
|
+
return default, "env_default", override_sources
|
|
223
|
+
|
|
224
|
+
# 3. f-string without variables (just a literal)
|
|
225
|
+
fstring_match = re.match(r'^f["\']([^{}"\']*)["\']\s*$', source)
|
|
226
|
+
if fstring_match:
|
|
227
|
+
return fstring_match.group(1), "literal", []
|
|
228
|
+
|
|
229
|
+
# 4. Integer literal
|
|
230
|
+
if source.isdigit():
|
|
231
|
+
return source, "literal", []
|
|
232
|
+
|
|
233
|
+
# 5. Name reference to another constant
|
|
234
|
+
if re.match(r"^[A-Z][A-Z0-9_]*$", source):
|
|
235
|
+
# It's a reference to another constant
|
|
236
|
+
if source in self._global_lookup:
|
|
237
|
+
return self._global_lookup[source], "reference", []
|
|
238
|
+
|
|
239
|
+
return None, "unknown", []
|
|
240
|
+
|
|
241
|
+
def _extract_config_classes(self, parsed: ParsedFile) -> None:
|
|
242
|
+
"""Extract configuration class definitions."""
|
|
243
|
+
file_path = parsed.path
|
|
244
|
+
|
|
245
|
+
for cls in parsed.classes:
|
|
246
|
+
# Check if it's a config class
|
|
247
|
+
is_config = self._is_config_class(cls)
|
|
248
|
+
if not is_config:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# Check for Pydantic Settings
|
|
252
|
+
is_settings = any(
|
|
253
|
+
base in {"BaseSettings", "pydantic_settings.BaseSettings", "pydantic.BaseSettings"}
|
|
254
|
+
for base in cls.base_classes
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
config = ConfigClass(
|
|
258
|
+
name=cls.name,
|
|
259
|
+
qualified_name=cls.qualified_name.full,
|
|
260
|
+
file_path=file_path,
|
|
261
|
+
is_pydantic_settings=is_settings,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Extract attributes from fields
|
|
265
|
+
for f in cls.fields:
|
|
266
|
+
value, source_type, overrides = self._extract_field_default(f, is_settings)
|
|
267
|
+
|
|
268
|
+
if value is not None:
|
|
269
|
+
config.attributes[f.name] = ResolvedConstant(
|
|
270
|
+
name=f"{cls.name}.{f.name}",
|
|
271
|
+
value=value,
|
|
272
|
+
source_file=file_path,
|
|
273
|
+
source_line=f.location.line if f.location else 0,
|
|
274
|
+
source_type=source_type,
|
|
275
|
+
is_default=is_settings or len(overrides) > 0,
|
|
276
|
+
override_sources=overrides,
|
|
277
|
+
description=f.description or "",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
self._config_classes[config.qualified_name] = config
|
|
281
|
+
|
|
282
|
+
# Also register by simple name
|
|
283
|
+
if cls.name not in self._config_classes:
|
|
284
|
+
self._config_classes[cls.name] = config
|
|
285
|
+
|
|
286
|
+
def _is_config_class(self, cls: ParsedClass) -> bool:
|
|
287
|
+
"""Check if a class is a configuration class."""
|
|
288
|
+
name_lower = cls.name.lower()
|
|
289
|
+
|
|
290
|
+
# Name-based detection
|
|
291
|
+
config_names = {"config", "settings", "configuration", "options", "params"}
|
|
292
|
+
if any(n in name_lower for n in config_names):
|
|
293
|
+
return True
|
|
294
|
+
|
|
295
|
+
# Base class detection
|
|
296
|
+
config_bases = {
|
|
297
|
+
"BaseSettings",
|
|
298
|
+
"BaseConfig",
|
|
299
|
+
"Configuration",
|
|
300
|
+
"pydantic.BaseSettings",
|
|
301
|
+
"pydantic_settings.BaseSettings",
|
|
302
|
+
}
|
|
303
|
+
return bool(any(base in config_bases for base in cls.base_classes))
|
|
304
|
+
|
|
305
|
+
def _extract_field_default(
|
|
306
|
+
self,
|
|
307
|
+
field,
|
|
308
|
+
is_pydantic: bool,
|
|
309
|
+
) -> tuple[str | None, str, list[str]]:
|
|
310
|
+
"""Extract default value from a class field."""
|
|
311
|
+
default = field.default_value
|
|
312
|
+
override_sources: list[str] = []
|
|
313
|
+
|
|
314
|
+
if not default:
|
|
315
|
+
# For Pydantic Settings, field might be set from env
|
|
316
|
+
if is_pydantic:
|
|
317
|
+
# Env var name is typically UPPER_CASE of field name
|
|
318
|
+
env_name = field.name.upper()
|
|
319
|
+
override_sources.append(f"ENV:{env_name}")
|
|
320
|
+
return None, "no_default", override_sources
|
|
321
|
+
|
|
322
|
+
# Try to extract the value
|
|
323
|
+
value, source_type, overrides = self._extract_constant_value(default)
|
|
324
|
+
override_sources.extend(overrides)
|
|
325
|
+
|
|
326
|
+
# For Pydantic Settings, always add potential env override
|
|
327
|
+
if is_pydantic and not any("ENV:" in o for o in override_sources):
|
|
328
|
+
env_name = field.name.upper()
|
|
329
|
+
override_sources.append(f"ENV:{env_name}")
|
|
330
|
+
|
|
331
|
+
return value, source_type, override_sources
|
|
332
|
+
|
|
333
|
+
def _extract_config_instances(self, parsed: ParsedFile) -> None:
|
|
334
|
+
"""Track assignments of config class instances."""
|
|
335
|
+
file_path = parsed.path
|
|
336
|
+
|
|
337
|
+
for assign in parsed.assignments:
|
|
338
|
+
if assign.in_function:
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
source = assign.source_call or ""
|
|
342
|
+
|
|
343
|
+
# Check if it's instantiating a known config class
|
|
344
|
+
resolved = self._resolve_call_name(source, file_path)
|
|
345
|
+
|
|
346
|
+
if resolved in self._config_classes:
|
|
347
|
+
self._instance_mapping[file_path][assign.target] = resolved
|
|
348
|
+
|
|
349
|
+
def _resolve_call_name(self, name: str, file_path: Path) -> str:
|
|
350
|
+
"""Resolve a call name using imports."""
|
|
351
|
+
imports = self._imports.get(file_path, {})
|
|
352
|
+
|
|
353
|
+
parts = name.split(".")
|
|
354
|
+
if parts[0] in imports:
|
|
355
|
+
resolved = imports[parts[0]]
|
|
356
|
+
if len(parts) > 1:
|
|
357
|
+
return f"{resolved}.{'.'.join(parts[1:])}"
|
|
358
|
+
return resolved
|
|
359
|
+
|
|
360
|
+
return name
|
|
361
|
+
|
|
362
|
+
# =========================================================================
|
|
363
|
+
# Resolution
|
|
364
|
+
# =========================================================================
|
|
365
|
+
|
|
366
|
+
def build_resolution_graph(self) -> None:
|
|
367
|
+
"""Build the resolution graph after processing all files."""
|
|
368
|
+
# Add all config class attributes to global lookup
|
|
369
|
+
for config in self._config_classes.values():
|
|
370
|
+
for attr_name, const in config.attributes.items():
|
|
371
|
+
# Register with full name
|
|
372
|
+
self._global_lookup[const.name] = const.value
|
|
373
|
+
# Register with class.attr format
|
|
374
|
+
self._global_lookup[f"{config.name}.{attr_name}"] = const.value
|
|
375
|
+
|
|
376
|
+
def resolve(
|
|
377
|
+
self,
|
|
378
|
+
expression: str,
|
|
379
|
+
in_file: Path | None = None,
|
|
380
|
+
) -> ResolvedConstant | None:
|
|
381
|
+
"""
|
|
382
|
+
Resolve a constant expression.
|
|
383
|
+
|
|
384
|
+
Handles:
|
|
385
|
+
- Simple names: API_PREFIX
|
|
386
|
+
- Attribute access: config.PREFIX
|
|
387
|
+
- Nested access: settings.api.prefix
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
expression: The expression to resolve
|
|
391
|
+
in_file: File context for local resolution
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
ResolvedConstant if resolvable, None otherwise
|
|
395
|
+
"""
|
|
396
|
+
expression = expression.strip()
|
|
397
|
+
|
|
398
|
+
# 1. Direct lookup
|
|
399
|
+
if expression in self._global_lookup:
|
|
400
|
+
return ResolvedConstant(
|
|
401
|
+
name=expression,
|
|
402
|
+
value=self._global_lookup[expression],
|
|
403
|
+
source_type="direct",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# 2. Check file-local constants
|
|
407
|
+
if in_file and in_file in self._module_constants:
|
|
408
|
+
if expression in self._module_constants[in_file]:
|
|
409
|
+
return self._module_constants[in_file][expression]
|
|
410
|
+
|
|
411
|
+
# 3. Handle attribute access: config.PREFIX
|
|
412
|
+
if "." in expression:
|
|
413
|
+
parts = expression.split(".")
|
|
414
|
+
|
|
415
|
+
# Try to resolve the base object
|
|
416
|
+
base = parts[0]
|
|
417
|
+
attr_path = ".".join(parts[1:])
|
|
418
|
+
|
|
419
|
+
# Check if base is a config instance in this file
|
|
420
|
+
if in_file and in_file in self._instance_mapping:
|
|
421
|
+
instance_map = self._instance_mapping[in_file]
|
|
422
|
+
if base in instance_map:
|
|
423
|
+
config_name = instance_map[base]
|
|
424
|
+
config = self._config_classes.get(config_name)
|
|
425
|
+
if config:
|
|
426
|
+
# Look up the attribute
|
|
427
|
+
if attr_path in config.attributes:
|
|
428
|
+
return config.attributes[attr_path]
|
|
429
|
+
# Try lowercase version
|
|
430
|
+
if attr_path.lower() in config.attributes:
|
|
431
|
+
return config.attributes[attr_path.lower()]
|
|
432
|
+
|
|
433
|
+
# Try looking up the full expression in global
|
|
434
|
+
if expression in self._global_lookup:
|
|
435
|
+
return ResolvedConstant(
|
|
436
|
+
name=expression,
|
|
437
|
+
value=self._global_lookup[expression],
|
|
438
|
+
source_type="config_attr",
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Try class.attr format directly
|
|
442
|
+
for config_name, _config in self._config_classes.items():
|
|
443
|
+
full_name = f"{config_name}.{attr_path}"
|
|
444
|
+
if full_name in self._global_lookup:
|
|
445
|
+
return ResolvedConstant(
|
|
446
|
+
name=full_name,
|
|
447
|
+
value=self._global_lookup[full_name],
|
|
448
|
+
source_type="config_attr",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
def resolve_value(
|
|
454
|
+
self,
|
|
455
|
+
expression: str,
|
|
456
|
+
in_file: Path | None = None,
|
|
457
|
+
) -> str | None:
|
|
458
|
+
"""Convenience method to just get the value."""
|
|
459
|
+
const = self.resolve(expression, in_file)
|
|
460
|
+
return const.value if const else None
|
|
461
|
+
|
|
462
|
+
def get_all_constants(self) -> dict[str, str]:
|
|
463
|
+
"""Get all resolved constants."""
|
|
464
|
+
return dict(self._global_lookup)
|
|
465
|
+
|
|
466
|
+
def get_config_classes(self) -> dict[str, ConfigClass]:
|
|
467
|
+
"""Get all detected configuration classes."""
|
|
468
|
+
return dict(self._config_classes)
|
|
469
|
+
|
|
470
|
+
def get_constants_for_file(self, file_path: Path) -> dict[str, ResolvedConstant]:
|
|
471
|
+
"""Get constants defined in a specific file."""
|
|
472
|
+
return self._module_constants.get(file_path, {})
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# =============================================================================
|
|
476
|
+
# Convenience Functions
|
|
477
|
+
# =============================================================================
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def build_constant_resolver(
|
|
481
|
+
parsed_files: list[ParsedFile],
|
|
482
|
+
project_root: Path | None = None,
|
|
483
|
+
) -> ConstantResolver:
|
|
484
|
+
"""
|
|
485
|
+
Build a constant resolver from parsed files.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
parsed_files: List of successfully parsed files
|
|
489
|
+
project_root: Optional project root
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Configured ConstantResolver
|
|
493
|
+
"""
|
|
494
|
+
resolver = ConstantResolver(project_root)
|
|
495
|
+
|
|
496
|
+
for parsed in parsed_files:
|
|
497
|
+
resolver.process_file(parsed)
|
|
498
|
+
|
|
499
|
+
resolver.build_resolution_graph()
|
|
500
|
+
return resolver
|