opencode-arch 1.0.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 (65) hide show
  1. opencode_arch/__init__.py +3 -0
  2. opencode_arch/artifacts/__init__.py +48 -0
  3. opencode_arch/artifacts/context.py +451 -0
  4. opencode_arch/artifacts/diagrams.py +451 -0
  5. opencode_arch/artifacts/selector.py +331 -0
  6. opencode_arch/artifacts/templates.py +444 -0
  7. opencode_arch/cli/__init__.py +1 -0
  8. opencode_arch/cli/bench.py +25 -0
  9. opencode_arch/cli/calibrate.py +208 -0
  10. opencode_arch/cli/confidence.py +66 -0
  11. opencode_arch/cli/docs.py +333 -0
  12. opencode_arch/cli/docs_validator.py +295 -0
  13. opencode_arch/cli/export_data.py +133 -0
  14. opencode_arch/cli/extract.py +93 -0
  15. opencode_arch/cli/gap_analyzer.py +107 -0
  16. opencode_arch/cli/generate.py +68 -0
  17. opencode_arch/cli/launch.py +264 -0
  18. opencode_arch/cli/main.py +360 -0
  19. opencode_arch/cli/metrics.py +186 -0
  20. opencode_arch/cli/prompts.py +20 -0
  21. opencode_arch/cli/regen_loop.py +1028 -0
  22. opencode_arch/context/__init__.py +29 -0
  23. opencode_arch/context/formatter.py +492 -0
  24. opencode_arch/context/pipeline_bridge.py +201 -0
  25. opencode_arch/extract/__init__.py +8 -0
  26. opencode_arch/extract/constraint_detector.py +398 -0
  27. opencode_arch/extract/from_artifacts.py +837 -0
  28. opencode_arch/extract/from_code.py +646 -0
  29. opencode_arch/extract/route_detector.py +400 -0
  30. opencode_arch/extract/table_parser.py +177 -0
  31. opencode_arch/learning/__init__.py +19 -0
  32. opencode_arch/learning/adapter.py +157 -0
  33. opencode_arch/learning/assessor.py +170 -0
  34. opencode_arch/learning/classifier.py +144 -0
  35. opencode_arch/learning/lessons.py +139 -0
  36. opencode_arch/learning/maintainer.py +281 -0
  37. opencode_arch/learning/patterns.py +51 -0
  38. opencode_arch/mcp/__init__.py +1 -0
  39. opencode_arch/mcp/__main__.py +8 -0
  40. opencode_arch/mcp/server.py +183 -0
  41. opencode_arch/mcp/tools/__init__.py +1 -0
  42. opencode_arch/mcp/tools/check.py +159 -0
  43. opencode_arch/mcp/tools/extract.py +107 -0
  44. opencode_arch/mcp/tools/feedback.py +65 -0
  45. opencode_arch/mcp/tools/generate.py +104 -0
  46. opencode_arch/mcp/tools/group.py +62 -0
  47. opencode_arch/mcp/tools/ingest.py +101 -0
  48. opencode_arch/mcp/tools/require.py +77 -0
  49. opencode_arch/mcp/tools/scan.py +53 -0
  50. opencode_arch/mcp/tools/slice.py +235 -0
  51. opencode_arch/mcp/tools/validate.py +59 -0
  52. opencode_arch/prompts/__init__.py +1 -0
  53. opencode_arch/prompts/regen.py +36 -0
  54. opencode_arch/runner/__init__.py +5 -0
  55. opencode_arch/runner/base.py +21 -0
  56. opencode_arch/runner/opencode.py +66 -0
  57. opencode_arch/telemetry/__init__.py +6 -0
  58. opencode_arch/telemetry/collector.py +40 -0
  59. opencode_arch/telemetry/recorder.py +12 -0
  60. opencode_arch/telemetry/store.py +537 -0
  61. opencode_arch-1.0.0.dist-info/METADATA +247 -0
  62. opencode_arch-1.0.0.dist-info/RECORD +65 -0
  63. opencode_arch-1.0.0.dist-info/WHEEL +4 -0
  64. opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
  65. opencode_arch-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,201 @@
1
+ """
2
+ Pipeline Bridge: Connect the Architecture Model to the artifact generation pipeline.
3
+
4
+ This module provides the interface between:
5
+ - The existing _pipeline_manifest.py (code-grounded reality)
6
+ - The architecture model (structural + semantic truth)
7
+ - The artifact generation pipeline (_pipeline_artifacts.py, _pipeline_templates.py)
8
+
9
+ It replaces raw manifest slices with model-enriched context for LLM artifact generation.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ from architecture_model.core.parser import load_model, save_model
18
+ from architecture_model.core.merger import merge_manifest
19
+ from architecture_model.core.slicer import slice_by_fblock, slice_for_artifact
20
+ from architecture_model.core.validator import validate_model
21
+ from architecture_model.core.types import ArchitectureModel
22
+ from opencode_arch.context.formatter import (
23
+ format_model_context,
24
+ format_fblock_context,
25
+ format_artifact_context,
26
+ )
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Configuration — loaded from .architecture-model.yaml
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def _get_default_paths(project_root: Path) -> tuple[Path, Path, Path]:
35
+ """Resolve output paths from config."""
36
+ try:
37
+ from architecture_model.config.loader import get_config
38
+
39
+ config = get_config(project_root)
40
+ resolved = config.resolved_output()
41
+ return resolved.model, resolved.manifest, resolved.artifacts
42
+ except Exception:
43
+ # Fallback for backward compatibility
44
+ name = project_root.name
45
+ return (
46
+ project_root / f"output/{name}/architecture-model.yaml",
47
+ project_root / f"output/{name}/reality-manifest.json",
48
+ project_root / f"output/{name}/artifacts/stage2",
49
+ )
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Public API
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def get_model(
58
+ project_root: str | Path,
59
+ force_refresh: bool = False,
60
+ ) -> ArchitectureModel:
61
+ """
62
+ Load or generate the architecture model for the project.
63
+
64
+ If the model YAML exists and isn't stale, loads it.
65
+ If missing or force_refresh=True, re-extracts from artifacts and merges manifest.
66
+
67
+ Args:
68
+ project_root: Path to project root directory.
69
+ force_refresh: Force re-extraction even if model exists.
70
+
71
+ Returns:
72
+ Loaded and validated ArchitectureModel.
73
+ """
74
+ project_root = Path(project_root)
75
+ model_path, manifest_path, artifact_dir = _get_default_paths(project_root)
76
+
77
+ if model_path.exists() and not force_refresh:
78
+ return load_model(model_path)
79
+
80
+ # Re-extract
81
+ from opencode_arch.extract.from_artifacts import extract_from_artifacts
82
+
83
+ model = extract_from_artifacts(artifact_dir)
84
+
85
+ if manifest_path.exists():
86
+ merge_manifest(model, manifest_path)
87
+
88
+ save_model(model, model_path)
89
+ return model
90
+
91
+
92
+ def get_artifact_context(
93
+ project_root: str | Path,
94
+ artifact_name: str,
95
+ max_tokens: int = 3000,
96
+ ) -> str:
97
+ """
98
+ Get model-based context for artifact generation/regeneration.
99
+
100
+ This REPLACES the raw manifest slice with structured architectural context.
101
+ The returned string is injected into the LLM system prompt alongside the
102
+ manifest metrics (which are kept for ground-truth file counts).
103
+
104
+ Args:
105
+ project_root: Project root path.
106
+ artifact_name: Which artifact needs context.
107
+ max_tokens: Token budget for the context block.
108
+
109
+ Returns:
110
+ Formatted model context string for LLM prompt injection.
111
+ """
112
+ model = get_model(project_root)
113
+ return format_artifact_context(model, artifact_name, max_tokens=max_tokens)
114
+
115
+
116
+ def get_fblock_context(
117
+ project_root: str | Path,
118
+ f_block: str,
119
+ max_tokens: int = 2000,
120
+ ) -> str:
121
+ """
122
+ Get model-based context for a single F-block (for section regeneration).
123
+
124
+ Args:
125
+ project_root: Project root path.
126
+ f_block: F-block ID (e.g., "F3").
127
+ max_tokens: Token budget.
128
+
129
+ Returns:
130
+ Formatted F-block context string.
131
+ """
132
+ model = get_model(project_root)
133
+ return format_fblock_context(model, f_block, max_tokens=max_tokens, project_root=Path(project_root))
134
+
135
+
136
+ def get_model_summary(project_root: str | Path) -> dict[str, Any]:
137
+ """
138
+ Get a summary dict of the model for injection into pipeline metadata.
139
+
140
+ Returns dict with entity_count, relationship_count, validation score, etc.
141
+ """
142
+ model = get_model(project_root)
143
+ result = validate_model(model)
144
+
145
+ return {
146
+ "entity_count": model.entity_count,
147
+ "relationship_count": model.relationship_count,
148
+ "validation_score": result.score,
149
+ "entities": {
150
+ "actors": len(model.entities.actors),
151
+ "capabilities": len(model.entities.capabilities),
152
+ "behaviors": len(model.entities.behaviors),
153
+ "interfaces": len(model.entities.interfaces),
154
+ "constraints": len(model.entities.constraints),
155
+ "layers": len(model.entities.layers),
156
+ "components": len(model.entities.components),
157
+ },
158
+ "schema_version": model.meta.schema_version,
159
+ "manifest_hash": model.meta.manifest_hash,
160
+ }
161
+
162
+
163
+ def enrich_manifest_slice(
164
+ manifest_slice: str,
165
+ project_root: str | Path,
166
+ artifact_name: str,
167
+ max_model_tokens: int = 2000,
168
+ ) -> str:
169
+ """
170
+ Enrich an existing manifest slice with architecture model context.
171
+
172
+ This is the BACKWARD-COMPATIBLE integration point. The existing pipeline
173
+ generates manifest slices via _pipeline_manifest.py — this function
174
+ prepends model context to that slice, giving the LLM both:
175
+ 1. Architectural structure (from model) — WHAT things mean, how they relate
176
+ 2. Code-grounded metrics (from manifest) — WHAT actually exists
177
+
178
+ Args:
179
+ manifest_slice: Raw manifest slice text (from _pipeline_manifest.py).
180
+ project_root: Project root path.
181
+ artifact_name: Which artifact this slice is for.
182
+ max_model_tokens: Token budget for model context portion.
183
+
184
+ Returns:
185
+ Combined context: model context + separator + manifest metrics.
186
+ """
187
+ try:
188
+ model_context = get_artifact_context(
189
+ project_root, artifact_name, max_tokens=max_model_tokens
190
+ )
191
+ except Exception:
192
+ # If model loading fails, fall back to manifest-only
193
+ return manifest_slice
194
+
195
+ return (
196
+ f"=== ARCHITECTURE MODEL CONTEXT ===\n"
197
+ f"{model_context}\n"
198
+ f"\n"
199
+ f"=== CODE-GROUNDED MANIFEST (verified metrics) ===\n"
200
+ f"{manifest_slice}"
201
+ )
@@ -0,0 +1,8 @@
1
+ """
2
+ Extract architecture models from source code or Tier 1 artifacts.
3
+ """
4
+
5
+ from .from_code import extract_from_code
6
+ from .from_artifacts import extract_from_artifacts
7
+
8
+ __all__ = ["extract_from_code", "extract_from_artifacts"]
@@ -0,0 +1,398 @@
1
+ """
2
+ Parse project configuration files to derive technical and organizational constraints.
3
+
4
+ Scans pyproject.toml, Dockerfile, .env templates, CI config, and setup.cfg to
5
+ automatically detect constraints such as Python version requirements, framework
6
+ choices, runtime images, exposed ports, and CI/CD platform.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import tomllib
13
+ from pathlib import Path
14
+
15
+ from architecture_model.core.types import Constraint, ConstraintType, Status
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Key frameworks to detect in dependencies
20
+ # ---------------------------------------------------------------------------
21
+
22
+ _KEY_FRAMEWORKS: set[str] = {
23
+ "fastapi",
24
+ "flask",
25
+ "django",
26
+ "starlette",
27
+ "aiohttp",
28
+ "tornado",
29
+ "sqlalchemy",
30
+ "asyncpg",
31
+ "psycopg2",
32
+ "pymongo",
33
+ "redis",
34
+ "celery",
35
+ }
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Public API
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def detect_constraints(project_root: Path) -> list[Constraint]:
44
+ """Scan project configuration files for derivable constraints.
45
+
46
+ Args:
47
+ project_root: Root directory of the project.
48
+
49
+ Returns:
50
+ List of Constraint entities derived from config files.
51
+ """
52
+ constraints: list[Constraint] = []
53
+ tc_counter = 0
54
+ oc_counter = 0
55
+
56
+ # --- pyproject.toml ---
57
+ tc_counter, oc_counter = _parse_pyproject(
58
+ project_root, constraints, tc_counter, oc_counter
59
+ )
60
+
61
+ # --- Dockerfile ---
62
+ tc_counter, oc_counter = _parse_dockerfile(
63
+ project_root, constraints, tc_counter, oc_counter
64
+ )
65
+
66
+ # --- .env.example / .env.template ---
67
+ oc_counter = _parse_env_template(project_root, constraints, oc_counter)
68
+
69
+ # --- CI/CD config ---
70
+ oc_counter = _parse_ci_config(project_root, constraints, oc_counter)
71
+
72
+ # --- setup.cfg fallback for python_requires ---
73
+ tc_counter, oc_counter = _parse_setup_cfg(
74
+ project_root, constraints, tc_counter, oc_counter
75
+ )
76
+
77
+ return constraints
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # pyproject.toml parsing
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ def _parse_pyproject(
86
+ project_root: Path,
87
+ constraints: list[Constraint],
88
+ tc_counter: int,
89
+ oc_counter: int,
90
+ ) -> tuple[int, int]:
91
+ """Extract constraints from pyproject.toml."""
92
+ pyproject_path = project_root / "pyproject.toml"
93
+ if not pyproject_path.is_file():
94
+ return tc_counter, oc_counter
95
+
96
+ try:
97
+ data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
98
+ except (OSError, tomllib.TOMLDecodeError):
99
+ return tc_counter, oc_counter
100
+
101
+ # requires-python
102
+ requires_python = data.get("project", {}).get("requires-python", "")
103
+ if requires_python:
104
+ tc_counter += 1
105
+ constraints.append(
106
+ Constraint(
107
+ id=f"TC-{tc_counter:02d}",
108
+ name=f"Python {requires_python}",
109
+ status=Status.ACTIVE,
110
+ description=f"Project requires Python {requires_python}",
111
+ type=ConstraintType.TECHNOLOGY,
112
+ metric="python_version",
113
+ threshold=requires_python,
114
+ rationale="Detected from pyproject.toml requires-python",
115
+ )
116
+ )
117
+
118
+ # dependencies (key frameworks)
119
+ deps = _collect_dependencies(data)
120
+ for dep_name in sorted(deps):
121
+ normalized = _normalize_dep_name(dep_name)
122
+ if normalized in _KEY_FRAMEWORKS:
123
+ tc_counter += 1
124
+ constraints.append(
125
+ Constraint(
126
+ id=f"TC-{tc_counter:02d}",
127
+ name=f"Dependency: {normalized}",
128
+ status=Status.ACTIVE,
129
+ description=f"Project depends on {normalized}",
130
+ tags=[normalized],
131
+ type=ConstraintType.TECHNOLOGY,
132
+ rationale="Detected from pyproject.toml dependencies",
133
+ )
134
+ )
135
+
136
+ return tc_counter, oc_counter
137
+
138
+
139
+ def _collect_dependencies(data: dict) -> list[str]:
140
+ """Collect dependency names from pyproject.toml data.
141
+
142
+ Checks [project.dependencies] and [tool.poetry.dependencies].
143
+ """
144
+ deps: list[str] = []
145
+
146
+ # PEP 621 style
147
+ project_deps = data.get("project", {}).get("dependencies", [])
148
+ for dep in project_deps:
149
+ # Parse requirement specifier: "fastapi>=0.100" -> "fastapi"
150
+ name = re.split(r"[>=<!\[;@\s]", dep, maxsplit=1)[0].strip()
151
+ if name:
152
+ deps.append(name)
153
+
154
+ # Poetry style
155
+ poetry_deps = (
156
+ data.get("tool", {}).get("poetry", {}).get("dependencies", {})
157
+ )
158
+ if isinstance(poetry_deps, dict):
159
+ for name in poetry_deps:
160
+ if name.lower() != "python":
161
+ deps.append(name)
162
+
163
+ return deps
164
+
165
+
166
+ def _normalize_dep_name(name: str) -> str:
167
+ """Normalize a dependency name for comparison (lowercase, hyphens to underscores removed)."""
168
+ return re.sub(r"[-_.]", "", name.lower())
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Dockerfile parsing
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def _parse_dockerfile(
177
+ project_root: Path,
178
+ constraints: list[Constraint],
179
+ tc_counter: int,
180
+ oc_counter: int,
181
+ ) -> tuple[int, int]:
182
+ """Extract constraints from Dockerfile."""
183
+ dockerfile_path = project_root / "Dockerfile"
184
+ if not dockerfile_path.is_file():
185
+ return tc_counter, oc_counter
186
+
187
+ try:
188
+ content = dockerfile_path.read_text(encoding="utf-8")
189
+ except OSError:
190
+ return tc_counter, oc_counter
191
+
192
+ # FROM line (first non-comment FROM)
193
+ from_match = re.search(
194
+ r"^\s*FROM\s+(\S+)", content, re.MULTILINE | re.IGNORECASE
195
+ )
196
+ if from_match:
197
+ base_image = from_match.group(1)
198
+ tc_counter += 1
199
+ constraints.append(
200
+ Constraint(
201
+ id=f"TC-{tc_counter:02d}",
202
+ name=f"Base image: {base_image}",
203
+ status=Status.ACTIVE,
204
+ description=f"Container runtime uses base image {base_image}",
205
+ type=ConstraintType.TECHNOLOGY,
206
+ metric="base_image",
207
+ threshold=base_image,
208
+ rationale="Detected from Dockerfile FROM directive",
209
+ )
210
+ )
211
+
212
+ # EXPOSE lines
213
+ expose_matches = re.findall(
214
+ r"^\s*EXPOSE\s+(.+)$", content, re.MULTILINE | re.IGNORECASE
215
+ )
216
+ ports: list[str] = []
217
+ for match in expose_matches:
218
+ # EXPOSE can have multiple ports: "EXPOSE 8000 8080"
219
+ found = re.findall(r"\d+", match)
220
+ ports.extend(found)
221
+
222
+ if ports:
223
+ tc_counter += 1
224
+ port_str = ", ".join(ports)
225
+ constraints.append(
226
+ Constraint(
227
+ id=f"TC-{tc_counter:02d}",
228
+ name=f"Exposed ports: {port_str}",
229
+ status=Status.ACTIVE,
230
+ description=f"Container exposes network port(s): {port_str}",
231
+ tags=["networking"],
232
+ type=ConstraintType.TECHNOLOGY,
233
+ metric="exposed_ports",
234
+ threshold=port_str,
235
+ rationale="Detected from Dockerfile EXPOSE directive",
236
+ )
237
+ )
238
+
239
+ return tc_counter, oc_counter
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # .env template parsing
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ def _parse_env_template(
248
+ project_root: Path,
249
+ constraints: list[Constraint],
250
+ oc_counter: int,
251
+ ) -> int:
252
+ """Count required env vars from .env.example or .env.template."""
253
+ env_path: Path | None = None
254
+ for name in (".env.example", ".env.template"):
255
+ candidate = project_root / name
256
+ if candidate.is_file():
257
+ env_path = candidate
258
+ break
259
+
260
+ if env_path is None:
261
+ return oc_counter
262
+
263
+ try:
264
+ content = env_path.read_text(encoding="utf-8")
265
+ except OSError:
266
+ return oc_counter
267
+
268
+ # Count non-empty, non-comment lines that look like KEY=...
269
+ var_count = 0
270
+ for line in content.splitlines():
271
+ stripped = line.strip()
272
+ if stripped and not stripped.startswith("#"):
273
+ if re.match(r"^[A-Za-z_][A-Za-z0-9_]*\s*=", stripped):
274
+ var_count += 1
275
+
276
+ if var_count > 0:
277
+ oc_counter += 1
278
+ constraints.append(
279
+ Constraint(
280
+ id=f"OC-{oc_counter:02d}",
281
+ name=f"Environment configuration ({var_count} vars)",
282
+ status=Status.ACTIVE,
283
+ description=(
284
+ f"Application requires {var_count} environment variable(s) "
285
+ f"as defined in {env_path.name}"
286
+ ),
287
+ tags=["configuration"],
288
+ type=ConstraintType.OPERATIONAL,
289
+ metric="env_var_count",
290
+ threshold=str(var_count),
291
+ rationale=f"Detected from {env_path.name}",
292
+ )
293
+ )
294
+
295
+ return oc_counter
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # CI/CD config parsing
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ def _parse_ci_config(
304
+ project_root: Path,
305
+ constraints: list[Constraint],
306
+ oc_counter: int,
307
+ ) -> int:
308
+ """Detect CI/CD platform from workflow config files."""
309
+ # GitHub Actions
310
+ workflows_dir = project_root / ".github" / "workflows"
311
+ if workflows_dir.is_dir():
312
+ yml_files = list(workflows_dir.glob("*.yml")) + list(
313
+ workflows_dir.glob("*.yaml")
314
+ )
315
+ if yml_files:
316
+ oc_counter += 1
317
+ constraints.append(
318
+ Constraint(
319
+ id=f"OC-{oc_counter:02d}",
320
+ name="CI/CD: GitHub Actions",
321
+ status=Status.ACTIVE,
322
+ description="Project uses GitHub Actions for CI/CD",
323
+ tags=["ci-cd", "github"],
324
+ type=ConstraintType.OPERATIONAL,
325
+ rationale="Detected from .github/workflows/*.yml",
326
+ )
327
+ )
328
+ return oc_counter
329
+
330
+ # GitLab CI
331
+ gitlab_ci = project_root / ".gitlab-ci.yml"
332
+ if gitlab_ci.is_file():
333
+ oc_counter += 1
334
+ constraints.append(
335
+ Constraint(
336
+ id=f"OC-{oc_counter:02d}",
337
+ name="CI/CD: GitLab CI",
338
+ status=Status.ACTIVE,
339
+ description="Project uses GitLab CI/CD",
340
+ tags=["ci-cd", "gitlab"],
341
+ type=ConstraintType.OPERATIONAL,
342
+ rationale="Detected from .gitlab-ci.yml",
343
+ )
344
+ )
345
+
346
+ return oc_counter
347
+
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # setup.cfg fallback
351
+ # ---------------------------------------------------------------------------
352
+
353
+
354
+ def _parse_setup_cfg(
355
+ project_root: Path,
356
+ constraints: list[Constraint],
357
+ tc_counter: int,
358
+ oc_counter: int,
359
+ ) -> tuple[int, int]:
360
+ """Extract python_requires from setup.cfg as fallback.
361
+
362
+ Only adds a constraint if no Python version constraint was already found
363
+ from pyproject.toml.
364
+ """
365
+ # Skip if we already have a python version constraint
366
+ if any(c.metric == "python_version" for c in constraints):
367
+ return tc_counter, oc_counter
368
+
369
+ setup_cfg_path = project_root / "setup.cfg"
370
+ if not setup_cfg_path.is_file():
371
+ return tc_counter, oc_counter
372
+
373
+ try:
374
+ content = setup_cfg_path.read_text(encoding="utf-8")
375
+ except OSError:
376
+ return tc_counter, oc_counter
377
+
378
+ # Look for python_requires under [options]
379
+ match = re.search(
380
+ r"^\s*python_requires\s*=\s*(.+)$", content, re.MULTILINE
381
+ )
382
+ if match:
383
+ requires = match.group(1).strip()
384
+ tc_counter += 1
385
+ constraints.append(
386
+ Constraint(
387
+ id=f"TC-{tc_counter:02d}",
388
+ name=f"Python {requires}",
389
+ status=Status.ACTIVE,
390
+ description=f"Project requires Python {requires}",
391
+ type=ConstraintType.TECHNOLOGY,
392
+ metric="python_version",
393
+ threshold=requires,
394
+ rationale="Detected from setup.cfg python_requires",
395
+ )
396
+ )
397
+
398
+ return tc_counter, oc_counter