chp-adapter-ci 0.8.0__tar.gz

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.
@@ -0,0 +1,23 @@
1
+ node_modules/
2
+ dist/
3
+ .yalc/
4
+ yalc.lock
5
+ *.tgz
6
+ .env
7
+ .env.*
8
+ coverage/
9
+ dashboard/
10
+ components/
11
+ .tech-hub-cache/
12
+ __pycache__/
13
+ *.py[cod]
14
+ .pytest_cache/
15
+ .chp/
16
+ .DS_Store
17
+ .coverage
18
+ .forge/
19
+ .hypothesis/
20
+ .claude/
21
+
22
+ # chp-agent evidence store
23
+ .chp-agent/sessions.sqlite
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: chp-adapter-ci
3
+ Version: 0.8.0
4
+ Summary: CHP adapter for running package test suites as governed evidence-producing invocations
5
+ Keywords: capability-host-protocol,chp,ci,pytest,testing
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: chp-core
@@ -0,0 +1,3 @@
1
+ from .adapter import CIAdapter, CIConfig
2
+
3
+ __all__ = ["CIAdapter", "CIConfig"]
@@ -0,0 +1,227 @@
1
+ """CIAdapter — run package test suites as governed CHP capability invocations.
2
+
3
+ Each package's pytest run becomes a ctx.ainvoke("chp.adapters.ci.run_suite")
4
+ call so it appears as a first-class record in the correlation chain. The
5
+ run_all capability orchestrates the full suite via the lego-block pattern:
6
+ run_all → ctx.ainvoke(run_suite) × N packages → evidence per package
7
+
8
+ Evidence policy:
9
+ Emitted: package name, path, pass/fail counts, duration, exit code, summary.
10
+ NOT emitted: test output, failure messages (may contain paths or secrets).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from chp_core import BaseAdapter, capability
24
+
25
+ _EMITS = [
26
+ "ci_run_started",
27
+ "ci_run_completed",
28
+ "ci_suite_started",
29
+ "ci_suite_completed",
30
+ "ci_suite_failed",
31
+ ]
32
+
33
+ _PASSED_RE = re.compile(r"(\d+) passed")
34
+ _FAILED_RE = re.compile(r"(\d+) failed")
35
+ _ERROR_RE = re.compile(r"(\d+) error")
36
+
37
+
38
+ @dataclass
39
+ class CIConfig:
40
+ """Config for CIAdapter.
41
+
42
+ ``repo_root`` — root of the chp-dev repo; defaults to cwd() at call time.
43
+ ``python`` — Python executable to use for subprocess pytest invocations.
44
+ """
45
+
46
+ repo_root: str = ""
47
+ python: str = ""
48
+
49
+ def resolved_root(self) -> Path:
50
+ return Path(self.repo_root).expanduser().resolve() if self.repo_root else Path.cwd()
51
+
52
+ def resolved_python(self) -> str:
53
+ return self.python or sys.executable
54
+
55
+
56
+ class CIAdapter(BaseAdapter):
57
+ """Run package test suites as governed CHP invocations with per-suite evidence."""
58
+
59
+ adapter_id = "chp.adapters.ci"
60
+ adapter_name = "CI"
61
+ adapter_description = "Run pytest across CHP packages as governed capability calls. Each suite is a separate evidence-producing invocation."
62
+ adapter_category = "developer_tooling"
63
+ adapter_tags = ["ci", "testing", "pytest", "quality", "evidence"]
64
+
65
+ def __init__(self, config: CIConfig | None = None) -> None:
66
+ self._config = config or CIConfig()
67
+
68
+ def _discover_packages(self) -> list[dict[str, str]]:
69
+ """Find all packages under repo_root/packages/ that have a pyproject.toml."""
70
+ packages_dir = self._config.resolved_root() / "packages"
71
+ if not packages_dir.is_dir():
72
+ return []
73
+ return sorted(
74
+ [
75
+ {"name": p.name, "path": str(p)}
76
+ for p in packages_dir.iterdir()
77
+ if p.is_dir() and (p / "pyproject.toml").exists()
78
+ ],
79
+ key=lambda x: x["name"],
80
+ )
81
+
82
+ # ------------------------------------------------------------------
83
+ # run_suite
84
+ # ------------------------------------------------------------------
85
+
86
+ @capability(
87
+ id="chp.adapters.ci.run_suite",
88
+ version="1.0.0",
89
+ description="Run pytest for a single package and emit pass/fail evidence. Output is not recorded — only counts and duration.",
90
+ category="developer_tooling",
91
+ risk="low",
92
+ emits=_EMITS,
93
+ input_schema={
94
+ "type": "object",
95
+ "properties": {
96
+ "package_name": {"type": "string", "description": "Human-readable package name (e.g. chp-adapter-messages)"},
97
+ "package_path": {"type": "string", "description": "Absolute path to the package directory"},
98
+ },
99
+ "required": ["package_name", "package_path"],
100
+ "additionalProperties": False,
101
+ },
102
+ )
103
+ async def run_suite(self, ctx: Any, payload: dict) -> dict:
104
+ package_name: str = payload["package_name"]
105
+ package_path: str = payload["package_path"]
106
+
107
+ ctx.emit("ci_suite_started", {
108
+ "package": package_name,
109
+ "path": package_path,
110
+ }, redacted=False)
111
+
112
+ t0 = time.monotonic()
113
+ result = subprocess.run(
114
+ [self._config.resolved_python(), "-m", "pytest", "-q", "-o", "addopts="],
115
+ cwd=package_path,
116
+ capture_output=True,
117
+ text=True,
118
+ )
119
+ duration_ms = round((time.monotonic() - t0) * 1000)
120
+
121
+ output = (result.stdout + result.stderr).strip()
122
+ summary_line = next(
123
+ (ln for ln in reversed(output.splitlines()) if ln.strip()),
124
+ "",
125
+ )
126
+
127
+ passed = int(m.group(1)) if (m := _PASSED_RE.search(summary_line)) else 0
128
+ failed = int(m.group(1)) if (m := _FAILED_RE.search(summary_line)) else 0
129
+ errors = int(m.group(1)) if (m := _ERROR_RE.search(summary_line)) else 0
130
+ ok = result.returncode == 0
131
+
132
+ evidence = {
133
+ "package": package_name,
134
+ "passed": passed,
135
+ "failed": failed,
136
+ "errors": errors,
137
+ "duration_ms": duration_ms,
138
+ "exit_code": result.returncode,
139
+ "summary": summary_line[:200],
140
+ }
141
+
142
+ if ok:
143
+ ctx.emit("ci_suite_completed", evidence, redacted=False)
144
+ else:
145
+ ctx.emit("ci_suite_failed", evidence, redacted=False)
146
+
147
+ return {"ok": ok, **evidence}
148
+
149
+ # ------------------------------------------------------------------
150
+ # run_all
151
+ # ------------------------------------------------------------------
152
+
153
+ @capability(
154
+ id="chp.adapters.ci.run_all",
155
+ version="1.0.0",
156
+ description="Run pytest across all packages in the repo. Each suite is a separate ctx.ainvoke(run_suite) call — every package gets its own evidence record.",
157
+ category="developer_tooling",
158
+ risk="low",
159
+ emits=_EMITS,
160
+ input_schema={
161
+ "type": "object",
162
+ "properties": {
163
+ "packages": {
164
+ "type": "array",
165
+ "items": {"type": "string"},
166
+ "description": "Package names to run (default: all discovered packages)",
167
+ },
168
+ },
169
+ "additionalProperties": False,
170
+ },
171
+ )
172
+ async def run_all(self, ctx: Any, payload: dict) -> dict:
173
+ all_packages = self._discover_packages()
174
+ filter_names: list[str] | None = payload.get("packages") or None
175
+
176
+ packages = (
177
+ [p for p in all_packages if p["name"] in filter_names]
178
+ if filter_names
179
+ else all_packages
180
+ )
181
+
182
+ ctx.emit("ci_run_started", {
183
+ "package_count": len(packages),
184
+ "packages": [p["name"] for p in packages],
185
+ }, redacted=False)
186
+
187
+ suite_results: list[dict] = []
188
+ for pkg in packages:
189
+ result = await ctx.ainvoke(
190
+ "chp.adapters.ci.run_suite",
191
+ {"package_name": pkg["name"], "package_path": pkg["path"]},
192
+ )
193
+ if result.success:
194
+ suite_results.append(result.data)
195
+ else:
196
+ suite_results.append({
197
+ "package": pkg["name"],
198
+ "ok": False,
199
+ "passed": 0,
200
+ "failed": 0,
201
+ "errors": 1,
202
+ "duration_ms": 0,
203
+ "exit_code": -1,
204
+ "summary": str(result.error),
205
+ })
206
+
207
+ total_passed = sum(r.get("passed", 0) for r in suite_results)
208
+ total_failed = sum(r.get("failed", 0) + r.get("errors", 0) for r in suite_results)
209
+ failed_suites = [r["package"] for r in suite_results if not r.get("ok")]
210
+ ok = len(failed_suites) == 0
211
+
212
+ ctx.emit("ci_run_completed", {
213
+ "ok": ok,
214
+ "total_packages": len(packages),
215
+ "total_passed": total_passed,
216
+ "total_failed": total_failed,
217
+ "failed_suites": failed_suites,
218
+ }, redacted=False)
219
+
220
+ return {
221
+ "ok": ok,
222
+ "total_packages": len(packages),
223
+ "total_passed": total_passed,
224
+ "total_failed": total_failed,
225
+ "failed_suites": failed_suites,
226
+ "suites": suite_results,
227
+ }
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chp-adapter-ci"
7
+ version = "0.8.0"
8
+ description = "CHP adapter for running package test suites as governed evidence-producing invocations"
9
+ requires-python = ">=3.11"
10
+ dependencies = ["chp-core"]
11
+ keywords = ["chp", "capability-host-protocol", "ci", "testing", "pytest"]
12
+
13
+ [project.entry-points."chp.adapters"]
14
+ ci = "chp_adapter_ci:CIAdapter"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["chp_adapter_ci"]
18
+
19
+ [tool.pytest.ini_options]
20
+ asyncio_mode = "strict"
@@ -0,0 +1,263 @@
1
+ """Tests for chp-adapter-ci."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import textwrap
8
+ from pathlib import Path
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ import pytest
12
+
13
+ from chp_adapter_ci import CIAdapter, CIConfig
14
+ from chp_core import LocalCapabilityHost, register_adapter
15
+ from chp_core.store import SQLiteEvidenceStore
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def _make_host(repo_root: str = "") -> LocalCapabilityHost:
23
+ store = SQLiteEvidenceStore(":memory:")
24
+ host = LocalCapabilityHost(store=store)
25
+ config = CIConfig(repo_root=repo_root)
26
+ register_adapter(host, CIAdapter(config))
27
+ return host
28
+
29
+
30
+ def _invoke(host: LocalCapabilityHost, cap_id: str, payload: dict | None = None):
31
+ return asyncio.get_event_loop().run_until_complete(
32
+ host.ainvoke(cap_id, payload or {})
33
+ )
34
+
35
+
36
+ def _fake_run(returncode: int = 0, stdout: str = "1 passed in 0.1s", stderr: str = ""):
37
+ result = MagicMock()
38
+ result.returncode = returncode
39
+ result.stdout = stdout
40
+ result.stderr = stderr
41
+ return result
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # CIConfig
46
+ # ---------------------------------------------------------------------------
47
+
48
+ class TestCIConfig:
49
+ def test_resolved_root_defaults_to_cwd(self):
50
+ config = CIConfig()
51
+ assert config.resolved_root() == Path.cwd()
52
+
53
+ def test_resolved_root_expands_path(self, tmp_path):
54
+ config = CIConfig(repo_root=str(tmp_path))
55
+ assert config.resolved_root() == tmp_path
56
+
57
+ def test_resolved_python_defaults_to_sys(self):
58
+ import sys
59
+ config = CIConfig()
60
+ assert config.resolved_python() == sys.executable
61
+
62
+ def test_resolved_python_uses_override(self):
63
+ config = CIConfig(python="/custom/python3")
64
+ assert config.resolved_python() == "/custom/python3"
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # CIAdapter._discover_packages
69
+ # ---------------------------------------------------------------------------
70
+
71
+ class TestDiscoverPackages:
72
+ def test_finds_packages_with_pyproject(self, tmp_path):
73
+ pkg_a = tmp_path / "packages" / "pkg-a"
74
+ pkg_a.mkdir(parents=True)
75
+ (pkg_a / "pyproject.toml").write_text("[project]\nname = 'pkg-a'\n")
76
+
77
+ pkg_b = tmp_path / "packages" / "pkg-b"
78
+ pkg_b.mkdir(parents=True)
79
+ (pkg_b / "pyproject.toml").write_text("[project]\nname = 'pkg-b'\n")
80
+
81
+ no_toml = tmp_path / "packages" / "not-a-package"
82
+ no_toml.mkdir(parents=True)
83
+
84
+ adapter = CIAdapter(CIConfig(repo_root=str(tmp_path)))
85
+ packages = adapter._discover_packages()
86
+
87
+ names = [p["name"] for p in packages]
88
+ assert "pkg-a" in names
89
+ assert "pkg-b" in names
90
+ assert "not-a-package" not in names
91
+
92
+ def test_returns_empty_when_no_packages_dir(self, tmp_path):
93
+ adapter = CIAdapter(CIConfig(repo_root=str(tmp_path)))
94
+ assert adapter._discover_packages() == []
95
+
96
+ def test_sorted_alphabetically(self, tmp_path):
97
+ for name in ["pkg-z", "pkg-a", "pkg-m"]:
98
+ d = tmp_path / "packages" / name
99
+ d.mkdir(parents=True)
100
+ (d / "pyproject.toml").write_text("")
101
+
102
+ adapter = CIAdapter(CIConfig(repo_root=str(tmp_path)))
103
+ names = [p["name"] for p in adapter._discover_packages()]
104
+ assert names == sorted(names)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # run_suite capability
109
+ # ---------------------------------------------------------------------------
110
+
111
+ class TestRunSuite:
112
+ def test_passing_suite(self, tmp_path):
113
+ host = _make_host()
114
+ with patch("chp_adapter_ci.adapter.subprocess.run") as mock_run:
115
+ mock_run.return_value = _fake_run(0, "3 passed in 0.42s")
116
+ result = _invoke(host, "chp.adapters.ci.run_suite", {
117
+ "package_name": "my-pkg",
118
+ "package_path": str(tmp_path),
119
+ })
120
+
121
+ assert result.success
122
+ assert result.data["ok"] is True
123
+ assert result.data["passed"] == 3
124
+ assert result.data["failed"] == 0
125
+ assert result.data["exit_code"] == 0
126
+ assert result.data["package"] == "my-pkg"
127
+
128
+ def test_failing_suite(self, tmp_path):
129
+ host = _make_host()
130
+ with patch("chp_adapter_ci.adapter.subprocess.run") as mock_run:
131
+ mock_run.return_value = _fake_run(1, "2 passed, 1 failed in 0.5s")
132
+ result = _invoke(host, "chp.adapters.ci.run_suite", {
133
+ "package_name": "bad-pkg",
134
+ "package_path": str(tmp_path),
135
+ })
136
+
137
+ assert result.success
138
+ assert result.data["ok"] is False
139
+ assert result.data["passed"] == 2
140
+ assert result.data["failed"] == 1
141
+ assert result.data["exit_code"] == 1
142
+
143
+ def test_summary_truncated_at_200(self, tmp_path):
144
+ host = _make_host()
145
+ long_line = "x" * 300
146
+ with patch("chp_adapter_ci.adapter.subprocess.run") as mock_run:
147
+ mock_run.return_value = _fake_run(0, long_line)
148
+ result = _invoke(host, "chp.adapters.ci.run_suite", {
149
+ "package_name": "pkg",
150
+ "package_path": str(tmp_path),
151
+ })
152
+
153
+ assert len(result.data["summary"]) <= 200
154
+
155
+ def test_duration_ms_positive(self, tmp_path):
156
+ host = _make_host()
157
+ with patch("chp_adapter_ci.adapter.subprocess.run") as mock_run:
158
+ mock_run.return_value = _fake_run()
159
+ result = _invoke(host, "chp.adapters.ci.run_suite", {
160
+ "package_name": "pkg",
161
+ "package_path": str(tmp_path),
162
+ })
163
+ assert result.data["duration_ms"] >= 0
164
+
165
+ def test_subprocess_called_with_correct_args(self, tmp_path):
166
+ host = _make_host()
167
+ with patch("chp_adapter_ci.adapter.subprocess.run") as mock_run:
168
+ mock_run.return_value = _fake_run()
169
+ _invoke(host, "chp.adapters.ci.run_suite", {
170
+ "package_name": "pkg",
171
+ "package_path": str(tmp_path),
172
+ })
173
+
174
+ call_args = mock_run.call_args
175
+ cmd = call_args[0][0]
176
+ assert "-m" in cmd
177
+ assert "pytest" in cmd
178
+ assert call_args[1]["cwd"] == str(tmp_path)
179
+ assert call_args[1]["capture_output"] is True
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # run_all capability
184
+ # ---------------------------------------------------------------------------
185
+
186
+ class TestRunAll:
187
+ def test_all_pass(self, tmp_path):
188
+ for name in ["pkg-a", "pkg-b"]:
189
+ d = tmp_path / "packages" / name
190
+ d.mkdir(parents=True)
191
+ (d / "pyproject.toml").write_text("")
192
+
193
+ host = _make_host(repo_root=str(tmp_path))
194
+ with patch("chp_adapter_ci.adapter.subprocess.run") as mock_run:
195
+ mock_run.return_value = _fake_run(0, "5 passed in 0.1s")
196
+ result = _invoke(host, "chp.adapters.ci.run_all", {})
197
+
198
+ assert result.success
199
+ assert result.data["ok"] is True
200
+ assert result.data["total_packages"] == 2
201
+ assert result.data["total_passed"] == 10 # 5 per package
202
+ assert result.data["failed_suites"] == []
203
+
204
+ def test_one_fails(self, tmp_path):
205
+ for name in ["pkg-ok", "pkg-broken"]:
206
+ d = tmp_path / "packages" / name
207
+ d.mkdir(parents=True)
208
+ (d / "pyproject.toml").write_text("")
209
+
210
+ host = _make_host(repo_root=str(tmp_path))
211
+
212
+ def side_effect(cmd, cwd=None, **kwargs):
213
+ if "pkg-broken" in str(cwd):
214
+ return _fake_run(1, "1 failed in 0.1s")
215
+ return _fake_run(0, "3 passed in 0.1s")
216
+
217
+ with patch("chp_adapter_ci.adapter.subprocess.run", side_effect=side_effect):
218
+ result = _invoke(host, "chp.adapters.ci.run_all", {})
219
+
220
+ assert result.success
221
+ assert result.data["ok"] is False
222
+ assert "pkg-broken" in result.data["failed_suites"]
223
+ assert "pkg-ok" not in result.data["failed_suites"]
224
+
225
+ def test_package_filter(self, tmp_path):
226
+ for name in ["pkg-a", "pkg-b", "pkg-c"]:
227
+ d = tmp_path / "packages" / name
228
+ d.mkdir(parents=True)
229
+ (d / "pyproject.toml").write_text("")
230
+
231
+ host = _make_host(repo_root=str(tmp_path))
232
+ with patch("chp_adapter_ci.adapter.subprocess.run") as mock_run:
233
+ mock_run.return_value = _fake_run()
234
+ result = _invoke(host, "chp.adapters.ci.run_all", {
235
+ "packages": ["pkg-a", "pkg-c"],
236
+ })
237
+
238
+ assert result.data["total_packages"] == 2
239
+ ran = {r["package"] for r in result.data["suites"]}
240
+ assert ran == {"pkg-a", "pkg-c"}
241
+
242
+ def test_empty_repo_no_packages(self, tmp_path):
243
+ host = _make_host(repo_root=str(tmp_path))
244
+ result = _invoke(host, "chp.adapters.ci.run_all", {})
245
+
246
+ assert result.success
247
+ assert result.data["total_packages"] == 0
248
+ assert result.data["ok"] is True
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Conformance: adapter.py has no violations
253
+ # ---------------------------------------------------------------------------
254
+
255
+ class TestCIAdapterConformance:
256
+ def test_no_conformance_violations(self):
257
+ from chp_adapter_conformance import check_source_file
258
+ import chp_adapter_ci.adapter as mod
259
+ import inspect
260
+
261
+ src_path = inspect.getfile(mod)
262
+ violations = check_source_file(src_path)
263
+ assert not violations, f"CIAdapter has conformance violations: {violations}"