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.
Files changed (165) hide show
  1. arcgraph/__init__.py +41 -0
  2. arcgraph/_build_provenance.json +10 -0
  3. arcgraph/adapters/__init__.py +6 -0
  4. arcgraph/adapters/arq_adapter.py +369 -0
  5. arcgraph/adapters/celery_adapter.py +440 -0
  6. arcgraph/adapters/common.py +436 -0
  7. arcgraph/adapters/django_adapter.py +765 -0
  8. arcgraph/adapters/fastapi_adapter.py +415 -0
  9. arcgraph/adapters/logging_adapter.py +335 -0
  10. arcgraph/adapters/mcp_adapter.py +100 -0
  11. arcgraph/adapters/pydantic_adapter.py +290 -0
  12. arcgraph/adapters/pytest_adapter.py +221 -0
  13. arcgraph/adapters/pytest_collection.py +96 -0
  14. arcgraph/adapters/pytest_fixtures.py +371 -0
  15. arcgraph/adapters/pytest_types.py +251 -0
  16. arcgraph/adapters/registry.py +34 -0
  17. arcgraph/adapters/service_container_adapter.py +548 -0
  18. arcgraph/adapters/sqlalchemy_adapter.py +446 -0
  19. arcgraph/adapters/typer_click_adapter.py +292 -0
  20. arcgraph/analyzers/__init__.py +1 -0
  21. arcgraph/analyzers/bindings.py +782 -0
  22. arcgraph/analyzers/calls/__init__.py +6 -0
  23. arcgraph/analyzers/calls/analyzer.py +2921 -0
  24. arcgraph/analyzers/calls/constants.py +702 -0
  25. arcgraph/analyzers/calls/context.py +298 -0
  26. arcgraph/analyzers/calls/lexical.py +237 -0
  27. arcgraph/analyzers/coverage.py +826 -0
  28. arcgraph/analyzers/exports.py +46 -0
  29. arcgraph/analyzers/har_trace.py +323 -0
  30. arcgraph/analyzers/imports.py +190 -0
  31. arcgraph/analyzers/otel_trace.py +352 -0
  32. arcgraph/analyzers/precise_references.py +1199 -0
  33. arcgraph/analyzers/python_scopes.py +56 -0
  34. arcgraph/analyzers/runtime_trace.py +516 -0
  35. arcgraph/analyzers/similarity.py +430 -0
  36. arcgraph/analyzers/symbols.py +363 -0
  37. arcgraph/analyzers/type_unions.py +89 -0
  38. arcgraph/analyzers/types.py +2293 -0
  39. arcgraph/assets/__init__.py +1 -0
  40. arcgraph/assets/workbench/__init__.py +1 -0
  41. arcgraph/assets/workbench/index.html +2398 -0
  42. arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
  43. arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
  44. arcgraph/change/__init__.py +10 -0
  45. arcgraph/change/baseline.py +145 -0
  46. arcgraph/change/changed_regions.py +728 -0
  47. arcgraph/change/contracts.py +927 -0
  48. arcgraph/change/errors.py +88 -0
  49. arcgraph/change/evidence.py +289 -0
  50. arcgraph/change/graph_delta.py +1422 -0
  51. arcgraph/change/identities.py +1277 -0
  52. arcgraph/change/paths.py +234 -0
  53. arcgraph/change/pins.py +426 -0
  54. arcgraph/change/planner.py +504 -0
  55. arcgraph/change/protected_surfaces.py +328 -0
  56. arcgraph/change/provider.py +46 -0
  57. arcgraph/change/service.py +1030 -0
  58. arcgraph/change/source_provider.py +311 -0
  59. arcgraph/change/store.py +1138 -0
  60. arcgraph/change/trusted_runners.py +154 -0
  61. arcgraph/change/verdicts.py +141 -0
  62. arcgraph/change/verification_plan.py +113 -0
  63. arcgraph/change/verifier.py +579 -0
  64. arcgraph/core/__init__.py +1 -0
  65. arcgraph/core/assurance.py +343 -0
  66. arcgraph/core/binding_metrics.py +75 -0
  67. arcgraph/core/cleanup.py +701 -0
  68. arcgraph/core/confidence_profiles.py +108 -0
  69. arcgraph/core/evidence_manifest.py +862 -0
  70. arcgraph/core/expression_kinds.py +11 -0
  71. arcgraph/core/force_graph_export.py +1125 -0
  72. arcgraph/core/freshness.py +94 -0
  73. arcgraph/core/graph_store.py +1099 -0
  74. arcgraph/core/ids.py +206 -0
  75. arcgraph/core/language_tiers.py +150 -0
  76. arcgraph/core/merge.py +649 -0
  77. arcgraph/core/metadata.py +30 -0
  78. arcgraph/core/operation_lock.py +218 -0
  79. arcgraph/core/passes.py +142 -0
  80. arcgraph/core/payload_policy.py +708 -0
  81. arcgraph/core/precise_references.py +124 -0
  82. arcgraph/core/query_engine.py +3078 -0
  83. arcgraph/core/recovery.py +18 -0
  84. arcgraph/core/scanner.py +1454 -0
  85. arcgraph/core/schemas.py +700 -0
  86. arcgraph/core/semantic.py +263 -0
  87. arcgraph/core/semantic_metrics.py +619 -0
  88. arcgraph/core/sharing_retry.py +84 -0
  89. arcgraph/core/structural.py +260 -0
  90. arcgraph/core/target_resolver.py +486 -0
  91. arcgraph/core/type_metrics.py +67 -0
  92. arcgraph/core/unresolved_classification.py +471 -0
  93. arcgraph/core/utils.py +100 -0
  94. arcgraph/core/visual_contract.py +240 -0
  95. arcgraph/core/visual_slice_engine.py +933 -0
  96. arcgraph/interfaces/__init__.py +1 -0
  97. arcgraph/interfaces/agent_capabilities.py +493 -0
  98. arcgraph/interfaces/agent_help.py +313 -0
  99. arcgraph/interfaces/benchmark.py +548 -0
  100. arcgraph/interfaces/ci.py +1992 -0
  101. arcgraph/interfaces/cli.py +2799 -0
  102. arcgraph/interfaces/cli_change.py +690 -0
  103. arcgraph/interfaces/cli_feedback.py +176 -0
  104. arcgraph/interfaces/cli_ops.py +69 -0
  105. arcgraph/interfaces/cli_support.py +113 -0
  106. arcgraph/interfaces/cli_trial.py +42 -0
  107. arcgraph/interfaces/cli_visual.py +601 -0
  108. arcgraph/interfaces/cli_workspace.py +300 -0
  109. arcgraph/interfaces/client_setup.py +481 -0
  110. arcgraph/interfaces/docs.py +1255 -0
  111. arcgraph/interfaces/human_output.py +409 -0
  112. arcgraph/interfaces/local_state.py +577 -0
  113. arcgraph/interfaces/mcp_server.py +457 -0
  114. arcgraph/interfaces/mcp_tools.py +1209 -0
  115. arcgraph/interfaces/metrics.py +571 -0
  116. arcgraph/interfaces/ops.py +330 -0
  117. arcgraph/interfaces/precision.py +1670 -0
  118. arcgraph/interfaces/project_probe.py +75 -0
  119. arcgraph/interfaces/reports.py +1121 -0
  120. arcgraph/interfaces/stdio_encoding.py +29 -0
  121. arcgraph/interfaces/trace.py +592 -0
  122. arcgraph/interfaces/trial_feedback.py +519 -0
  123. arcgraph/interfaces/trial_setup.py +535 -0
  124. arcgraph/interfaces/visual_server.py +354 -0
  125. arcgraph/interfaces/visual_slice.py +168 -0
  126. arcgraph/interfaces/visual_smoke.py +666 -0
  127. arcgraph/interfaces/workbench.py +576 -0
  128. arcgraph/interfaces/workspace.py +1513 -0
  129. arcgraph/pipeline/__init__.py +1 -0
  130. arcgraph/pipeline/contracts.py +122 -0
  131. arcgraph/pipeline/external_frontend.py +451 -0
  132. arcgraph/pipeline/external_l3_frontends.py +368 -0
  133. arcgraph/pipeline/external_protocol.py +358 -0
  134. arcgraph/pipeline/frontends.py +437 -0
  135. arcgraph/pipeline/indexer.py +661 -0
  136. arcgraph/pipeline/openapi_frontend.py +708 -0
  137. arcgraph/pipeline/python_frontend.py +298 -0
  138. arcgraph/pipeline/reindexer.py +1333 -0
  139. arcgraph/pipeline/scip_frontend.py +726 -0
  140. arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
  141. arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
  142. arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
  143. arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
  144. arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
  145. arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
  146. arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
  147. arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
  148. arcgraph/pipeline/typescript_extractor.mjs +177 -0
  149. arcgraph/pipeline/typescript_frameworks.py +947 -0
  150. arcgraph/pipeline/typescript_frontend.py +656 -0
  151. arcgraph/pipeline/typescript_references.mjs +167 -0
  152. arcgraph/providers/__init__.py +27 -0
  153. arcgraph/providers/context_provider.py +1258 -0
  154. arcgraph/providers/flow_payload.py +117 -0
  155. arcgraph/providers/memory_connector.py +216 -0
  156. arcgraph/providers/orchestrator.py +475 -0
  157. arcgraph/providers/relationship_payload.py +102 -0
  158. arcgraph/providers/risk_provider.py +843 -0
  159. arcgraph/providers/why_provider.py +349 -0
  160. arcgraph/version_info.py +246 -0
  161. arcgraph-0.1.0.dist-info/METADATA +676 -0
  162. arcgraph-0.1.0.dist-info/RECORD +165 -0
  163. arcgraph-0.1.0.dist-info/WHEEL +4 -0
  164. arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
  165. 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
+ )