arcgraph 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.
- arcgraph/__init__.py +41 -0
- arcgraph/_build_provenance.json +10 -0
- arcgraph/adapters/__init__.py +6 -0
- arcgraph/adapters/arq_adapter.py +369 -0
- arcgraph/adapters/celery_adapter.py +440 -0
- arcgraph/adapters/common.py +436 -0
- arcgraph/adapters/django_adapter.py +765 -0
- arcgraph/adapters/fastapi_adapter.py +415 -0
- arcgraph/adapters/logging_adapter.py +335 -0
- arcgraph/adapters/mcp_adapter.py +100 -0
- arcgraph/adapters/pydantic_adapter.py +290 -0
- arcgraph/adapters/pytest_adapter.py +221 -0
- arcgraph/adapters/pytest_collection.py +96 -0
- arcgraph/adapters/pytest_fixtures.py +371 -0
- arcgraph/adapters/pytest_types.py +251 -0
- arcgraph/adapters/registry.py +34 -0
- arcgraph/adapters/service_container_adapter.py +548 -0
- arcgraph/adapters/sqlalchemy_adapter.py +446 -0
- arcgraph/adapters/typer_click_adapter.py +292 -0
- arcgraph/analyzers/__init__.py +1 -0
- arcgraph/analyzers/bindings.py +782 -0
- arcgraph/analyzers/calls/__init__.py +6 -0
- arcgraph/analyzers/calls/analyzer.py +2921 -0
- arcgraph/analyzers/calls/constants.py +702 -0
- arcgraph/analyzers/calls/context.py +298 -0
- arcgraph/analyzers/calls/lexical.py +237 -0
- arcgraph/analyzers/coverage.py +826 -0
- arcgraph/analyzers/exports.py +46 -0
- arcgraph/analyzers/har_trace.py +323 -0
- arcgraph/analyzers/imports.py +190 -0
- arcgraph/analyzers/otel_trace.py +352 -0
- arcgraph/analyzers/precise_references.py +1199 -0
- arcgraph/analyzers/python_scopes.py +56 -0
- arcgraph/analyzers/runtime_trace.py +516 -0
- arcgraph/analyzers/similarity.py +430 -0
- arcgraph/analyzers/symbols.py +363 -0
- arcgraph/analyzers/type_unions.py +89 -0
- arcgraph/analyzers/types.py +2293 -0
- arcgraph/assets/__init__.py +1 -0
- arcgraph/assets/workbench/__init__.py +1 -0
- arcgraph/assets/workbench/index.html +2398 -0
- arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
- arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
- arcgraph/change/__init__.py +10 -0
- arcgraph/change/baseline.py +145 -0
- arcgraph/change/changed_regions.py +728 -0
- arcgraph/change/contracts.py +927 -0
- arcgraph/change/errors.py +88 -0
- arcgraph/change/evidence.py +289 -0
- arcgraph/change/graph_delta.py +1422 -0
- arcgraph/change/identities.py +1277 -0
- arcgraph/change/paths.py +234 -0
- arcgraph/change/pins.py +426 -0
- arcgraph/change/planner.py +504 -0
- arcgraph/change/protected_surfaces.py +328 -0
- arcgraph/change/provider.py +46 -0
- arcgraph/change/service.py +1030 -0
- arcgraph/change/source_provider.py +311 -0
- arcgraph/change/store.py +1138 -0
- arcgraph/change/trusted_runners.py +154 -0
- arcgraph/change/verdicts.py +141 -0
- arcgraph/change/verification_plan.py +113 -0
- arcgraph/change/verifier.py +579 -0
- arcgraph/core/__init__.py +1 -0
- arcgraph/core/assurance.py +343 -0
- arcgraph/core/binding_metrics.py +75 -0
- arcgraph/core/cleanup.py +701 -0
- arcgraph/core/confidence_profiles.py +108 -0
- arcgraph/core/evidence_manifest.py +862 -0
- arcgraph/core/expression_kinds.py +11 -0
- arcgraph/core/force_graph_export.py +1125 -0
- arcgraph/core/freshness.py +94 -0
- arcgraph/core/graph_store.py +1099 -0
- arcgraph/core/ids.py +206 -0
- arcgraph/core/language_tiers.py +150 -0
- arcgraph/core/merge.py +649 -0
- arcgraph/core/metadata.py +30 -0
- arcgraph/core/operation_lock.py +218 -0
- arcgraph/core/passes.py +142 -0
- arcgraph/core/payload_policy.py +708 -0
- arcgraph/core/precise_references.py +124 -0
- arcgraph/core/query_engine.py +3078 -0
- arcgraph/core/recovery.py +18 -0
- arcgraph/core/scanner.py +1454 -0
- arcgraph/core/schemas.py +700 -0
- arcgraph/core/semantic.py +263 -0
- arcgraph/core/semantic_metrics.py +619 -0
- arcgraph/core/sharing_retry.py +84 -0
- arcgraph/core/structural.py +260 -0
- arcgraph/core/target_resolver.py +486 -0
- arcgraph/core/type_metrics.py +67 -0
- arcgraph/core/unresolved_classification.py +471 -0
- arcgraph/core/utils.py +100 -0
- arcgraph/core/visual_contract.py +240 -0
- arcgraph/core/visual_slice_engine.py +933 -0
- arcgraph/interfaces/__init__.py +1 -0
- arcgraph/interfaces/agent_capabilities.py +493 -0
- arcgraph/interfaces/agent_help.py +313 -0
- arcgraph/interfaces/benchmark.py +548 -0
- arcgraph/interfaces/ci.py +1992 -0
- arcgraph/interfaces/cli.py +2799 -0
- arcgraph/interfaces/cli_change.py +690 -0
- arcgraph/interfaces/cli_feedback.py +176 -0
- arcgraph/interfaces/cli_ops.py +69 -0
- arcgraph/interfaces/cli_support.py +113 -0
- arcgraph/interfaces/cli_trial.py +42 -0
- arcgraph/interfaces/cli_visual.py +601 -0
- arcgraph/interfaces/cli_workspace.py +300 -0
- arcgraph/interfaces/client_setup.py +481 -0
- arcgraph/interfaces/docs.py +1255 -0
- arcgraph/interfaces/human_output.py +409 -0
- arcgraph/interfaces/local_state.py +577 -0
- arcgraph/interfaces/mcp_server.py +457 -0
- arcgraph/interfaces/mcp_tools.py +1209 -0
- arcgraph/interfaces/metrics.py +571 -0
- arcgraph/interfaces/ops.py +330 -0
- arcgraph/interfaces/precision.py +1670 -0
- arcgraph/interfaces/project_probe.py +75 -0
- arcgraph/interfaces/reports.py +1121 -0
- arcgraph/interfaces/stdio_encoding.py +29 -0
- arcgraph/interfaces/trace.py +592 -0
- arcgraph/interfaces/trial_feedback.py +519 -0
- arcgraph/interfaces/trial_setup.py +535 -0
- arcgraph/interfaces/visual_server.py +354 -0
- arcgraph/interfaces/visual_slice.py +168 -0
- arcgraph/interfaces/visual_smoke.py +666 -0
- arcgraph/interfaces/workbench.py +576 -0
- arcgraph/interfaces/workspace.py +1513 -0
- arcgraph/pipeline/__init__.py +1 -0
- arcgraph/pipeline/contracts.py +122 -0
- arcgraph/pipeline/external_frontend.py +451 -0
- arcgraph/pipeline/external_l3_frontends.py +368 -0
- arcgraph/pipeline/external_protocol.py +358 -0
- arcgraph/pipeline/frontends.py +437 -0
- arcgraph/pipeline/indexer.py +661 -0
- arcgraph/pipeline/openapi_frontend.py +708 -0
- arcgraph/pipeline/python_frontend.py +298 -0
- arcgraph/pipeline/reindexer.py +1333 -0
- arcgraph/pipeline/scip_frontend.py +726 -0
- arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
- arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
- arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
- arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
- arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
- arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
- arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
- arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
- arcgraph/pipeline/typescript_extractor.mjs +177 -0
- arcgraph/pipeline/typescript_frameworks.py +947 -0
- arcgraph/pipeline/typescript_frontend.py +656 -0
- arcgraph/pipeline/typescript_references.mjs +167 -0
- arcgraph/providers/__init__.py +27 -0
- arcgraph/providers/context_provider.py +1258 -0
- arcgraph/providers/flow_payload.py +117 -0
- arcgraph/providers/memory_connector.py +216 -0
- arcgraph/providers/orchestrator.py +475 -0
- arcgraph/providers/relationship_payload.py +102 -0
- arcgraph/providers/risk_provider.py +843 -0
- arcgraph/providers/why_provider.py +349 -0
- arcgraph/version_info.py +246 -0
- arcgraph-0.1.0.dist-info/METADATA +676 -0
- arcgraph-0.1.0.dist-info/RECORD +165 -0
- arcgraph-0.1.0.dist-info/WHEEL +4 -0
- arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
- arcgraph-0.1.0.dist-info/licenses/LICENSE +203 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Pydantic model, field, and settings extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from arcgraph.adapters.common import (
|
|
9
|
+
AdapterAnalysis,
|
|
10
|
+
ImportAliases,
|
|
11
|
+
SemanticAdapter,
|
|
12
|
+
annotation_name,
|
|
13
|
+
call_name,
|
|
14
|
+
import_aliases,
|
|
15
|
+
keyword,
|
|
16
|
+
literal_str,
|
|
17
|
+
make_adapter_edge,
|
|
18
|
+
resolve_name,
|
|
19
|
+
)
|
|
20
|
+
from arcgraph.core.ids import (
|
|
21
|
+
class_id,
|
|
22
|
+
config_key_id,
|
|
23
|
+
model_field_id,
|
|
24
|
+
pydantic_model_id,
|
|
25
|
+
)
|
|
26
|
+
from arcgraph.core.schemas import Edge, FileRecord, Node
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class PydanticField:
|
|
31
|
+
name: str
|
|
32
|
+
annotation: str | None
|
|
33
|
+
node: ast.AST
|
|
34
|
+
value: ast.AST | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PydanticAdapter(SemanticAdapter):
|
|
38
|
+
name = "pydantic"
|
|
39
|
+
capabilities = ("resource:pydantic_model", "resource:config", "edge:declares")
|
|
40
|
+
required_facts = ("class",)
|
|
41
|
+
|
|
42
|
+
MODEL_BASES = {"BaseModel", "pydantic.BaseModel"}
|
|
43
|
+
SETTINGS_BASES = {
|
|
44
|
+
"BaseSettings",
|
|
45
|
+
"pydantic.BaseSettings",
|
|
46
|
+
"pydantic_settings.BaseSettings",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def analyze(
|
|
50
|
+
self,
|
|
51
|
+
files: list[FileRecord],
|
|
52
|
+
parsed_files: dict[str, ast.Module],
|
|
53
|
+
nodes: list[Node],
|
|
54
|
+
module_names: set[str],
|
|
55
|
+
) -> AdapterAnalysis:
|
|
56
|
+
del nodes
|
|
57
|
+
analysis = AdapterAnalysis()
|
|
58
|
+
model_count = 0
|
|
59
|
+
settings_count = 0
|
|
60
|
+
field_count = 0
|
|
61
|
+
config_key_count = 0
|
|
62
|
+
|
|
63
|
+
for file_record in files:
|
|
64
|
+
tree = parsed_files.get(file_record.path)
|
|
65
|
+
if tree is None:
|
|
66
|
+
continue
|
|
67
|
+
imports = import_aliases(file_record, tree, module_names)
|
|
68
|
+
classes = [item for item in tree.body if isinstance(item, ast.ClassDef)]
|
|
69
|
+
for stmt in classes:
|
|
70
|
+
kind = self._pydantic_kind(stmt, file_record, imports)
|
|
71
|
+
if kind is None:
|
|
72
|
+
continue
|
|
73
|
+
qualname = f"{file_record.module}.{stmt.name}"
|
|
74
|
+
class_node_id = class_id(qualname)
|
|
75
|
+
schema_node = Node(
|
|
76
|
+
id=pydantic_model_id(qualname),
|
|
77
|
+
kind="pydantic_model",
|
|
78
|
+
name=stmt.name,
|
|
79
|
+
qualname=qualname,
|
|
80
|
+
path=file_record.path,
|
|
81
|
+
start_line=stmt.lineno,
|
|
82
|
+
end_line=getattr(stmt, "end_lineno", stmt.lineno),
|
|
83
|
+
properties={"model_kind": kind},
|
|
84
|
+
)
|
|
85
|
+
analysis.nodes.append(schema_node)
|
|
86
|
+
analysis.edges.append(
|
|
87
|
+
self._edge(
|
|
88
|
+
class_node_id,
|
|
89
|
+
schema_node.id,
|
|
90
|
+
"declares",
|
|
91
|
+
"pydantic_model",
|
|
92
|
+
file_record,
|
|
93
|
+
stmt,
|
|
94
|
+
f"{stmt.name} declares {kind}",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
model_count += 1
|
|
98
|
+
settings_count += 1 if kind == "settings" else 0
|
|
99
|
+
|
|
100
|
+
for field in self._fields(stmt):
|
|
101
|
+
field_node = self._field_node(qualname, field, file_record, kind)
|
|
102
|
+
analysis.nodes.append(field_node)
|
|
103
|
+
analysis.edges.append(
|
|
104
|
+
self._edge(
|
|
105
|
+
class_node_id,
|
|
106
|
+
field_node.id,
|
|
107
|
+
"declares",
|
|
108
|
+
"pydantic_field",
|
|
109
|
+
file_record,
|
|
110
|
+
field.node,
|
|
111
|
+
f"{stmt.name}.{field.name}",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
field_count += 1
|
|
115
|
+
if kind == "settings":
|
|
116
|
+
config_node, env_key = self._config_node(
|
|
117
|
+
stmt, field, file_record
|
|
118
|
+
)
|
|
119
|
+
analysis.nodes.append(config_node)
|
|
120
|
+
analysis.edges.append(
|
|
121
|
+
self._edge(
|
|
122
|
+
class_node_id,
|
|
123
|
+
config_node.id,
|
|
124
|
+
"configures",
|
|
125
|
+
"pydantic_settings",
|
|
126
|
+
file_record,
|
|
127
|
+
field.node,
|
|
128
|
+
f"{stmt.name}.{field.name} -> {env_key}",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
analysis.edges.append(
|
|
132
|
+
self._edge(
|
|
133
|
+
field_node.id,
|
|
134
|
+
config_node.id,
|
|
135
|
+
"maps_to",
|
|
136
|
+
"pydantic_settings",
|
|
137
|
+
file_record,
|
|
138
|
+
field.node,
|
|
139
|
+
f"{field.name} maps to {env_key}",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
config_key_count += 1
|
|
143
|
+
|
|
144
|
+
analysis.nodes.sort(key=lambda node: node.id)
|
|
145
|
+
analysis.edges.sort(key=lambda edge: (edge.source, edge.target, edge.kind))
|
|
146
|
+
analysis.metrics = {
|
|
147
|
+
"pydantic_models": model_count,
|
|
148
|
+
"settings_models": settings_count,
|
|
149
|
+
"model_fields": field_count,
|
|
150
|
+
"config_keys": config_key_count,
|
|
151
|
+
"discovered_registrations": model_count,
|
|
152
|
+
"resolved_handlers": model_count,
|
|
153
|
+
"unresolved_registrations": 0,
|
|
154
|
+
}
|
|
155
|
+
return analysis
|
|
156
|
+
|
|
157
|
+
def _pydantic_kind(
|
|
158
|
+
self,
|
|
159
|
+
stmt: ast.ClassDef,
|
|
160
|
+
file_record: FileRecord,
|
|
161
|
+
imports: ImportAliases,
|
|
162
|
+
) -> str | None:
|
|
163
|
+
resolved_bases = {
|
|
164
|
+
self._resolved_annotation(base, file_record, imports) for base in stmt.bases
|
|
165
|
+
}
|
|
166
|
+
if resolved_bases & self.SETTINGS_BASES:
|
|
167
|
+
return "settings"
|
|
168
|
+
if resolved_bases & self.MODEL_BASES:
|
|
169
|
+
return "model"
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _resolved_annotation(
|
|
174
|
+
node: ast.AST, file_record: FileRecord, imports: ImportAliases
|
|
175
|
+
) -> str | None:
|
|
176
|
+
name = annotation_name(node)
|
|
177
|
+
if not name:
|
|
178
|
+
return None
|
|
179
|
+
resolved = resolve_name(name, file_record, imports)
|
|
180
|
+
return resolved if "." in resolved else name.rsplit(".", 1)[-1]
|
|
181
|
+
|
|
182
|
+
def _fields(self, stmt: ast.ClassDef) -> list[PydanticField]:
|
|
183
|
+
fields: list[PydanticField] = []
|
|
184
|
+
for item in stmt.body:
|
|
185
|
+
if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
|
|
186
|
+
fields.append(
|
|
187
|
+
PydanticField(
|
|
188
|
+
name=item.target.id,
|
|
189
|
+
annotation=annotation_name(item.annotation),
|
|
190
|
+
node=item,
|
|
191
|
+
value=item.value,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
elif isinstance(item, ast.Assign):
|
|
195
|
+
for target in item.targets:
|
|
196
|
+
if isinstance(target, ast.Name) and self._is_field_call(item.value):
|
|
197
|
+
fields.append(
|
|
198
|
+
PydanticField(
|
|
199
|
+
name=target.id,
|
|
200
|
+
annotation=None,
|
|
201
|
+
node=item,
|
|
202
|
+
value=item.value,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
return fields
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _is_field_call(node: ast.AST | None) -> bool:
|
|
209
|
+
if not isinstance(node, ast.Call):
|
|
210
|
+
return False
|
|
211
|
+
name = call_name(node.func)
|
|
212
|
+
return bool(name and name.rsplit(".", 1)[-1] in {"Field", "PrivateAttr"})
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _field_node(
|
|
216
|
+
model_qualname: str,
|
|
217
|
+
field: PydanticField,
|
|
218
|
+
file_record: FileRecord,
|
|
219
|
+
model_kind: str,
|
|
220
|
+
) -> Node:
|
|
221
|
+
qualname = f"{model_qualname}.{field.name}"
|
|
222
|
+
return Node(
|
|
223
|
+
id=model_field_id(qualname),
|
|
224
|
+
kind="model_field",
|
|
225
|
+
name=field.name,
|
|
226
|
+
qualname=qualname,
|
|
227
|
+
path=file_record.path,
|
|
228
|
+
start_line=getattr(field.node, "lineno", None),
|
|
229
|
+
end_line=getattr(
|
|
230
|
+
field.node, "end_lineno", getattr(field.node, "lineno", None)
|
|
231
|
+
),
|
|
232
|
+
properties={"annotation": field.annotation, "model_kind": model_kind},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _config_node(
|
|
236
|
+
self,
|
|
237
|
+
stmt: ast.ClassDef,
|
|
238
|
+
field: PydanticField,
|
|
239
|
+
file_record: FileRecord,
|
|
240
|
+
) -> tuple[Node, str]:
|
|
241
|
+
env_key = self._field_env_key(field) or field.name.upper()
|
|
242
|
+
node_id = config_key_id(f"env:{env_key}")
|
|
243
|
+
return (
|
|
244
|
+
Node(
|
|
245
|
+
id=node_id,
|
|
246
|
+
kind="config",
|
|
247
|
+
name=env_key,
|
|
248
|
+
qualname=env_key,
|
|
249
|
+
path=file_record.path,
|
|
250
|
+
start_line=getattr(field.node, "lineno", stmt.lineno),
|
|
251
|
+
properties={
|
|
252
|
+
"source": "pydantic_settings",
|
|
253
|
+
"settings_class": stmt.name,
|
|
254
|
+
"field": field.name,
|
|
255
|
+
"key": env_key,
|
|
256
|
+
},
|
|
257
|
+
),
|
|
258
|
+
env_key,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _field_env_key(field: PydanticField) -> str | None:
|
|
263
|
+
if not isinstance(field.value, ast.Call):
|
|
264
|
+
return None
|
|
265
|
+
for key in ("env", "validation_alias", "alias"):
|
|
266
|
+
value = literal_str(keyword(field.value, key))
|
|
267
|
+
if value:
|
|
268
|
+
return value
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _edge(
|
|
273
|
+
source: str,
|
|
274
|
+
target: str,
|
|
275
|
+
kind: str,
|
|
276
|
+
evidence_kind: str,
|
|
277
|
+
file_record: FileRecord,
|
|
278
|
+
node: ast.AST,
|
|
279
|
+
detail: str,
|
|
280
|
+
) -> Edge:
|
|
281
|
+
return make_adapter_edge(
|
|
282
|
+
source=source,
|
|
283
|
+
target=target,
|
|
284
|
+
kind=kind,
|
|
285
|
+
confidence="heuristic",
|
|
286
|
+
evidence_kind=evidence_kind,
|
|
287
|
+
file_record=file_record,
|
|
288
|
+
node=node,
|
|
289
|
+
detail=detail,
|
|
290
|
+
)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""pytest test case and fixture extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
from arcgraph.adapters.common import (
|
|
8
|
+
AdapterAnalysis,
|
|
9
|
+
SemanticAdapter,
|
|
10
|
+
call_name,
|
|
11
|
+
iter_symbol_defs,
|
|
12
|
+
literal_str,
|
|
13
|
+
make_adapter_edge,
|
|
14
|
+
symbol_id_for_def,
|
|
15
|
+
)
|
|
16
|
+
from arcgraph.adapters.pytest_fixtures import FixtureCatalog
|
|
17
|
+
from arcgraph.core.ids import pytest_case_id
|
|
18
|
+
from arcgraph.core.schemas import Edge, FileRecord, Node
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PytestAdapter(SemanticAdapter):
|
|
22
|
+
name = "pytest"
|
|
23
|
+
capabilities = ("entrypoint:test_case", "resource:pytest_fixture", "edge:injects")
|
|
24
|
+
required_facts = ("function",)
|
|
25
|
+
BUILTIN_FIXTURE_ARGS = {
|
|
26
|
+
"anyio_backend",
|
|
27
|
+
"benchmark",
|
|
28
|
+
"cache",
|
|
29
|
+
"capfd",
|
|
30
|
+
"capfdbinary",
|
|
31
|
+
"caplog",
|
|
32
|
+
"capsys",
|
|
33
|
+
"capsysbinary",
|
|
34
|
+
"cls",
|
|
35
|
+
"doctest_namespace",
|
|
36
|
+
"event_loop",
|
|
37
|
+
"monkeypatch",
|
|
38
|
+
"pytestconfig",
|
|
39
|
+
"record_property",
|
|
40
|
+
"record_testsuite_property",
|
|
41
|
+
"record_xml_attribute",
|
|
42
|
+
"recwarn",
|
|
43
|
+
"request",
|
|
44
|
+
"self",
|
|
45
|
+
"tmp_path",
|
|
46
|
+
"tmp_path_factory",
|
|
47
|
+
"tmpdir",
|
|
48
|
+
"tmpdir_factory",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def analyze(
|
|
52
|
+
self,
|
|
53
|
+
files: list[FileRecord],
|
|
54
|
+
parsed_files: dict[str, ast.Module],
|
|
55
|
+
nodes: list[Node],
|
|
56
|
+
module_names: set[str],
|
|
57
|
+
) -> AdapterAnalysis:
|
|
58
|
+
analysis = AdapterAnalysis()
|
|
59
|
+
catalog = FixtureCatalog(files, parsed_files, module_names, nodes)
|
|
60
|
+
fixture_count = 0
|
|
61
|
+
test_count = 0
|
|
62
|
+
injection_count = 0
|
|
63
|
+
unresolved_fixture_args = 0
|
|
64
|
+
|
|
65
|
+
for fixture in catalog.fixtures:
|
|
66
|
+
file_record, stmt = fixture.file, fixture.stmt
|
|
67
|
+
qualname = fixture.node_id.removeprefix("fixture:")
|
|
68
|
+
analysis.nodes.append(
|
|
69
|
+
Node(
|
|
70
|
+
id=fixture.node_id,
|
|
71
|
+
kind="pytest_fixture",
|
|
72
|
+
name=fixture.name,
|
|
73
|
+
qualname=qualname,
|
|
74
|
+
path=file_record.path,
|
|
75
|
+
start_line=stmt.lineno,
|
|
76
|
+
end_line=stmt.end_lineno,
|
|
77
|
+
properties={
|
|
78
|
+
"handler": fixture.handler_id,
|
|
79
|
+
"autouse": fixture.autouse,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
analysis.edges.append(
|
|
84
|
+
self._edge(
|
|
85
|
+
fixture.node_id,
|
|
86
|
+
fixture.handler_id,
|
|
87
|
+
"invokes",
|
|
88
|
+
file_record,
|
|
89
|
+
stmt,
|
|
90
|
+
f"pytest fixture {fixture.name}",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
fixture_count += 1
|
|
94
|
+
|
|
95
|
+
for file_record in files:
|
|
96
|
+
tree = parsed_files.get(file_record.path)
|
|
97
|
+
if tree is None:
|
|
98
|
+
continue
|
|
99
|
+
for stmt, qualified_name, id_factory in iter_symbol_defs(tree):
|
|
100
|
+
if not isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
101
|
+
continue
|
|
102
|
+
if not catalog.collection.is_test(
|
|
103
|
+
file_record, stmt, qualified_name.rpartition(".")[0]
|
|
104
|
+
):
|
|
105
|
+
continue
|
|
106
|
+
qualname = f"{file_record.module}.{qualified_name}"
|
|
107
|
+
source_id = symbol_id_for_def(file_record, qualified_name, id_factory)
|
|
108
|
+
test_node_id = pytest_case_id(qualname)
|
|
109
|
+
marks = self._marks(stmt)
|
|
110
|
+
analysis.nodes.append(
|
|
111
|
+
Node(
|
|
112
|
+
id=test_node_id,
|
|
113
|
+
kind="test_case",
|
|
114
|
+
name=stmt.name,
|
|
115
|
+
qualname=qualname,
|
|
116
|
+
path=file_record.path,
|
|
117
|
+
start_line=stmt.lineno,
|
|
118
|
+
end_line=getattr(stmt, "end_lineno", stmt.lineno),
|
|
119
|
+
properties={
|
|
120
|
+
"handler": source_id,
|
|
121
|
+
"marks": marks,
|
|
122
|
+
"parametrized": "parametrize" in marks,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
analysis.edges.append(
|
|
127
|
+
self._edge(
|
|
128
|
+
test_node_id,
|
|
129
|
+
source_id,
|
|
130
|
+
"invokes",
|
|
131
|
+
file_record,
|
|
132
|
+
stmt,
|
|
133
|
+
f"pytest test {stmt.name}",
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
test_count += 1
|
|
137
|
+
owner = qualified_name.rpartition(".")[0]
|
|
138
|
+
parametrized_args = catalog.direct_parameters(stmt, file_record, owner)
|
|
139
|
+
requested_fixtures = [
|
|
140
|
+
*catalog.parameters(stmt, owner),
|
|
141
|
+
*self._usefixtures_args(stmt),
|
|
142
|
+
*catalog.autouse_names(file_record, owner),
|
|
143
|
+
]
|
|
144
|
+
for arg in dict.fromkeys(requested_fixtures):
|
|
145
|
+
if arg in parametrized_args:
|
|
146
|
+
continue
|
|
147
|
+
fixture = catalog.resolve(arg, file_record, owner)
|
|
148
|
+
if fixture is None:
|
|
149
|
+
if (
|
|
150
|
+
arg not in self.BUILTIN_FIXTURE_ARGS
|
|
151
|
+
and arg not in parametrized_args
|
|
152
|
+
):
|
|
153
|
+
unresolved_fixture_args += 1
|
|
154
|
+
continue
|
|
155
|
+
analysis.edges.append(
|
|
156
|
+
self._edge(
|
|
157
|
+
test_node_id,
|
|
158
|
+
fixture.node_id,
|
|
159
|
+
"injects",
|
|
160
|
+
file_record,
|
|
161
|
+
stmt,
|
|
162
|
+
f"pytest fixture argument {arg}",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
injection_count += 1
|
|
166
|
+
|
|
167
|
+
analysis.nodes.sort(key=lambda node: node.id)
|
|
168
|
+
analysis.edges.sort(key=lambda edge: (edge.source, edge.target, edge.kind))
|
|
169
|
+
analysis.metrics = {
|
|
170
|
+
"test_cases": test_count,
|
|
171
|
+
"fixtures": fixture_count,
|
|
172
|
+
"fixture_injections": injection_count,
|
|
173
|
+
"unresolved_fixture_injections": unresolved_fixture_args,
|
|
174
|
+
"discovered_registrations": test_count + fixture_count,
|
|
175
|
+
"resolved_handlers": test_count + fixture_count,
|
|
176
|
+
"unresolved_registrations": 0,
|
|
177
|
+
}
|
|
178
|
+
return analysis
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _marks(stmt: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
182
|
+
marks: set[str] = set()
|
|
183
|
+
for decorator in stmt.decorator_list:
|
|
184
|
+
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
185
|
+
name = call_name(target)
|
|
186
|
+
if name and name.startswith("pytest.mark."):
|
|
187
|
+
marks.add(name.rsplit(".", 1)[-1])
|
|
188
|
+
return sorted(marks)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _usefixtures_args(stmt: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
192
|
+
names: list[str] = []
|
|
193
|
+
for decorator in stmt.decorator_list:
|
|
194
|
+
if not isinstance(decorator, ast.Call):
|
|
195
|
+
continue
|
|
196
|
+
name = call_name(decorator.func)
|
|
197
|
+
if name not in {"pytest.mark.usefixtures", "mark.usefixtures"}:
|
|
198
|
+
continue
|
|
199
|
+
names.extend(value for arg in decorator.args if (value := literal_str(arg)))
|
|
200
|
+
return names
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _edge(
|
|
204
|
+
source: str,
|
|
205
|
+
target: str,
|
|
206
|
+
kind: str,
|
|
207
|
+
file_record: FileRecord,
|
|
208
|
+
node: ast.AST,
|
|
209
|
+
detail: str,
|
|
210
|
+
) -> Edge:
|
|
211
|
+
return make_adapter_edge(
|
|
212
|
+
source=source,
|
|
213
|
+
target=target,
|
|
214
|
+
kind=kind,
|
|
215
|
+
confidence="confirmed",
|
|
216
|
+
evidence_kind="pytest_adapter",
|
|
217
|
+
file_record=file_record,
|
|
218
|
+
node=node,
|
|
219
|
+
detail=detail,
|
|
220
|
+
semantic_role=f"{kind}:pytest",
|
|
221
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Static pytest collection patterns; never import project configuration code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import configparser
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from fnmatch import fnmatchcase
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
import shlex
|
|
11
|
+
import tomllib
|
|
12
|
+
|
|
13
|
+
from arcgraph.core.schemas import FileRecord
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class PytestCollection:
|
|
18
|
+
python_files: tuple[str, ...] = ("test_*.py", "*_test.py")
|
|
19
|
+
python_functions: tuple[str, ...] = ("test",)
|
|
20
|
+
python_classes: tuple[str, ...] = ("Test",)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_files(cls, files: list[FileRecord]) -> PytestCollection:
|
|
24
|
+
if not files:
|
|
25
|
+
return cls()
|
|
26
|
+
file = files[0]
|
|
27
|
+
root = Path(file.abs_path)
|
|
28
|
+
for _ in PurePosixPath(file.path).parts:
|
|
29
|
+
root = root.parent
|
|
30
|
+
return cls.from_root(root)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_root(cls, root: Path) -> PytestCollection:
|
|
34
|
+
for name, section in (
|
|
35
|
+
("pytest.ini", "pytest"),
|
|
36
|
+
(".pytest.ini", "pytest"),
|
|
37
|
+
("pyproject.toml", ""),
|
|
38
|
+
("tox.ini", "pytest"),
|
|
39
|
+
("setup.cfg", "tool:pytest"),
|
|
40
|
+
):
|
|
41
|
+
path = root / name
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
if name == "pyproject.toml":
|
|
46
|
+
values = (
|
|
47
|
+
tomllib.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
.get("tool", {})
|
|
49
|
+
.get("pytest", {})
|
|
50
|
+
.get("ini_options")
|
|
51
|
+
)
|
|
52
|
+
if values is None:
|
|
53
|
+
continue
|
|
54
|
+
else:
|
|
55
|
+
config = configparser.ConfigParser(interpolation=None)
|
|
56
|
+
config.read_string(path.read_text(encoding="utf-8"))
|
|
57
|
+
if not config.has_section(section):
|
|
58
|
+
if name in {"pytest.ini", ".pytest.ini"}:
|
|
59
|
+
return cls()
|
|
60
|
+
continue
|
|
61
|
+
values = config[section]
|
|
62
|
+
defaults = cls()
|
|
63
|
+
patterns = {}
|
|
64
|
+
for key in ("python_files", "python_functions", "python_classes"):
|
|
65
|
+
value = values.get(key, getattr(defaults, key))
|
|
66
|
+
patterns[key] = tuple(
|
|
67
|
+
shlex.split(value) if isinstance(value, str) else value
|
|
68
|
+
)
|
|
69
|
+
if any(
|
|
70
|
+
not isinstance(p, str) for group in patterns.values() for p in group
|
|
71
|
+
):
|
|
72
|
+
return cls((), (), ())
|
|
73
|
+
return cls(**patterns)
|
|
74
|
+
except (OSError, ValueError, TypeError, AttributeError, configparser.Error):
|
|
75
|
+
# Invalid static configuration provides no collection evidence.
|
|
76
|
+
return cls((), (), ())
|
|
77
|
+
return cls()
|
|
78
|
+
|
|
79
|
+
def file_matches(self, path: str) -> bool:
|
|
80
|
+
return any(fnmatchcase(PurePosixPath(path).name, p) for p in self.python_files)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _name_matches(name: str, patterns: tuple[str, ...]) -> bool:
|
|
84
|
+
return any(name.startswith(p) or fnmatchcase(name, p) for p in patterns)
|
|
85
|
+
|
|
86
|
+
def is_test(
|
|
87
|
+
self,
|
|
88
|
+
file: FileRecord,
|
|
89
|
+
stmt: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
90
|
+
owner: str = "",
|
|
91
|
+
) -> bool:
|
|
92
|
+
return (
|
|
93
|
+
self.file_matches(file.path)
|
|
94
|
+
and self._name_matches(stmt.name, self.python_functions)
|
|
95
|
+
and (not owner or self._name_matches(owner, self.python_classes))
|
|
96
|
+
)
|