agent-suite-conformance 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.
@@ -0,0 +1,47 @@
1
+ """CLI contract v1 conformance kit (Plan 018 WI-2).
2
+
3
+ One centrally versioned package, owned by agent-suite, consumed pinned by
4
+ every component — never copied, so there is exactly one kit to drift from
5
+ (the identifier-gate distribution model applied to conformance).
6
+
7
+ ``KIT_VERSION`` identifies the kit in recorded conformance results
8
+ (``data/cli-conformance.json``); ``CLI_CONTRACT_VERSION`` is the contract
9
+ revision the kit enforces (``docs/cli-contract.md``).
10
+ """
11
+
12
+ from agent_suite.conformance.envelope import (
13
+ build_envelope,
14
+ emit_error,
15
+ validate_envelope,
16
+ )
17
+ from agent_suite.conformance.kit import (
18
+ BrokenPipeCase,
19
+ ErrorCase,
20
+ Framing,
21
+ SuccessCase,
22
+ UsageCase,
23
+ run_broken_pipe_case,
24
+ run_error_case,
25
+ run_success_case,
26
+ run_usage_case,
27
+ )
28
+
29
+ KIT_VERSION = "1.0.0"
30
+ CLI_CONTRACT_VERSION = 1
31
+
32
+ __all__ = [
33
+ "KIT_VERSION",
34
+ "CLI_CONTRACT_VERSION",
35
+ "BrokenPipeCase",
36
+ "ErrorCase",
37
+ "Framing",
38
+ "SuccessCase",
39
+ "UsageCase",
40
+ "build_envelope",
41
+ "emit_error",
42
+ "run_broken_pipe_case",
43
+ "run_error_case",
44
+ "run_success_case",
45
+ "run_usage_case",
46
+ "validate_envelope",
47
+ ]
@@ -0,0 +1,114 @@
1
+ """The common CLI error envelope (CLI contract v1 §3).
2
+
3
+ ``build_envelope`` constructs the shape, ``validate_envelope`` checks a
4
+ parsed document against it (stdlib-only — the schema at
5
+ ``data/cli-error-envelope.schema.json`` is the normative statement; this
6
+ validator mirrors it for consumers that must stay dependency-free), and
7
+ ``emit_error`` is the helper a conforming CLI calls on every error path.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import sys
15
+ from typing import Any
16
+
17
+ _CODE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
18
+
19
+
20
+ def build_envelope(
21
+ code: str,
22
+ message: str,
23
+ *,
24
+ detail: str | None = None,
25
+ retryable: bool = False,
26
+ partial: dict[str, Any] | None = None,
27
+ ) -> dict[str, Any]:
28
+ """Construct a contract-v1 error envelope."""
29
+ if not _CODE_RE.match(code):
30
+ raise ValueError(f"error code {code!r} is not SCREAMING_SNAKE")
31
+ return {
32
+ "ok": False,
33
+ "error": {
34
+ "code": code,
35
+ "message": message,
36
+ "detail": detail,
37
+ "retryable": retryable,
38
+ "partial": partial,
39
+ },
40
+ }
41
+
42
+
43
+ def validate_envelope(document: Any) -> list[str]:
44
+ """Return contract violations for a parsed error document (empty = valid)."""
45
+ violations: list[str] = []
46
+ if not isinstance(document, dict):
47
+ return ["envelope is not a JSON object"]
48
+ if document.get("ok") is not False:
49
+ violations.append("envelope 'ok' is not false")
50
+ error = document.get("error")
51
+ if not isinstance(error, dict):
52
+ return violations + ["envelope 'error' is not an object"]
53
+ code = error.get("code")
54
+ if not isinstance(code, str) or not _CODE_RE.match(code):
55
+ violations.append(f"error 'code' {code!r} is not a SCREAMING_SNAKE string")
56
+ if not isinstance(error.get("message"), str):
57
+ violations.append("error 'message' is not a string")
58
+ if "detail" in error and not isinstance(error["detail"], (str, type(None))):
59
+ violations.append("error 'detail' is not a string or null")
60
+ if not isinstance(error.get("retryable"), bool):
61
+ violations.append("error 'retryable' is not a boolean")
62
+ partial = error.get("partial")
63
+ if partial is not None:
64
+ if not isinstance(partial, dict):
65
+ violations.append("error 'partial' is not an object or null")
66
+ else:
67
+ succeeded = partial.get("succeeded")
68
+ failed = partial.get("failed")
69
+ if not isinstance(succeeded, int) or succeeded < 0:
70
+ violations.append("partial 'succeeded' is not a non-negative integer")
71
+ if not isinstance(failed, int) or failed < 1:
72
+ violations.append("partial 'failed' is not a positive integer")
73
+ unknown = set(error) - {"code", "message", "detail", "retryable", "partial"}
74
+ if unknown:
75
+ violations.append(f"error object has unknown fields: {sorted(unknown)}")
76
+ unknown_top = set(document) - {"ok", "error"}
77
+ if unknown_top:
78
+ violations.append(f"envelope has unknown fields: {sorted(unknown_top)}")
79
+ return violations
80
+
81
+
82
+ def emit_error(
83
+ code: str,
84
+ message: str,
85
+ *,
86
+ detail: str | None = None,
87
+ retryable: bool = False,
88
+ partial: dict[str, Any] | None = None,
89
+ json_mode: bool = False,
90
+ ) -> int:
91
+ """Report an error per the contract and return the exit code (1).
92
+
93
+ Under ``--json`` the envelope is the single stdout document; otherwise
94
+ the message (and detail) go to stderr. Either way the caller returns
95
+ the value returned here — no path prints an error and exits 0.
96
+ """
97
+ if json_mode:
98
+ print(
99
+ json.dumps(
100
+ build_envelope(
101
+ code,
102
+ message,
103
+ detail=detail,
104
+ retryable=retryable,
105
+ partial=partial,
106
+ ),
107
+ indent=2,
108
+ )
109
+ )
110
+ else:
111
+ print(f"error: {message}", file=sys.stderr)
112
+ if detail:
113
+ print(f" {detail}", file=sys.stderr)
114
+ return 1
@@ -0,0 +1,215 @@
1
+ """Behavioral conformance checks for CLI contract v1 (Plan 018 WI-2).
2
+
3
+ Each check runs a real subprocess against the component's CLI and returns
4
+ a list of contract violations (empty = conformant). Components declare
5
+ cases (fixtures) and parameterize pytest over them; the checks themselves
6
+ live only here, centrally versioned.
7
+
8
+ The checks prove the contract, not the implementation: stream discipline
9
+ (§1), exit-code taxonomy (§2), the error envelope (§3), traceback and
10
+ broken-pipe robustness (§4), and secret redaction (§3). Output honesty
11
+ (§5) and manifest discovery (§6) are P1 — cases for them extend these
12
+ dataclasses rather than forking the kit.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from dataclasses import dataclass, field
22
+ from enum import Enum
23
+ from typing import Mapping
24
+
25
+ from agent_suite.conformance.envelope import validate_envelope
26
+
27
+ _TRACEBACK_MARKER = "Traceback (most recent call last)"
28
+
29
+ # Value planted in per-case env vars to prove error paths never echo
30
+ # secret material (contract §3 redaction). Cases list the env var *names*
31
+ # whose values must never surface; the kit plants this sentinel there.
32
+ SENTINEL_SECRET = "conformance-sentinel-3f9d2c"
33
+
34
+
35
+ class Framing(Enum):
36
+ DOCUMENT = "document"
37
+ NDJSON = "ndjson"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class SuccessCase:
42
+ """A verb invocation that must succeed with pure JSON stdout."""
43
+
44
+ name: str
45
+ argv: tuple[str, ...]
46
+ framing: Framing = Framing.DOCUMENT
47
+ env: Mapping[str, str] = field(default_factory=dict)
48
+ unset_env: tuple[str, ...] = ()
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ErrorCase:
53
+ """A documented failure that must exit nonzero with the envelope."""
54
+
55
+ name: str
56
+ argv: tuple[str, ...]
57
+ expect_code: str | None = None
58
+ json_mode: bool = True
59
+ env: Mapping[str, str] = field(default_factory=dict)
60
+ unset_env: tuple[str, ...] = ()
61
+ secret_env_names: tuple[str, ...] = ()
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class UsageCase:
66
+ """A malformed invocation that must exit 2 (argparse usage error)."""
67
+
68
+ name: str
69
+ argv: tuple[str, ...]
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class BrokenPipeCase:
74
+ """A verb whose stdout is closed early; it must not traceback."""
75
+
76
+ name: str
77
+ argv: tuple[str, ...]
78
+ env: Mapping[str, str] = field(default_factory=dict)
79
+
80
+
81
+ CASE_TIMEOUT: float = 60.0
82
+
83
+
84
+ def _run(
85
+ argv: tuple[str, ...],
86
+ env: Mapping[str, str],
87
+ unset_env: tuple[str, ...] = (),
88
+ timeout: float = CASE_TIMEOUT,
89
+ ) -> subprocess.CompletedProcess[str]:
90
+ merged = {**os.environ, "PYTHONIOENCODING": "utf-8", **env}
91
+ for name in unset_env:
92
+ merged.pop(name, None)
93
+ try:
94
+ return subprocess.run(
95
+ list(argv),
96
+ capture_output=True,
97
+ text=True,
98
+ check=False,
99
+ env=merged,
100
+ timeout=timeout,
101
+ )
102
+ except subprocess.TimeoutExpired:
103
+ return subprocess.CompletedProcess(
104
+ args=list(argv),
105
+ returncode=124,
106
+ stdout="",
107
+ stderr=f"conformance: timed out after {timeout}s",
108
+ )
109
+
110
+
111
+ def run_success_case(case: SuccessCase) -> list[str]:
112
+ """Contract §1 + §2: exit 0, stdout is pure JSON, no traceback."""
113
+ proc = _run(case.argv, case.env, case.unset_env)
114
+ violations: list[str] = []
115
+ if proc.returncode != 0:
116
+ violations.append(
117
+ f"exit {proc.returncode}, expected 0; stderr: {proc.stderr[-500:]!r}"
118
+ )
119
+ if _TRACEBACK_MARKER in proc.stderr:
120
+ violations.append("traceback on a documented success path")
121
+ stdout = proc.stdout.strip()
122
+ if not stdout:
123
+ violations.append("empty stdout on a JSON success path")
124
+ return violations
125
+ match case.framing:
126
+ case Framing.DOCUMENT:
127
+ try:
128
+ json.loads(stdout)
129
+ except json.JSONDecodeError as exc:
130
+ violations.append(
131
+ f"stdout is not a single JSON document ({exc}); "
132
+ f"first 200 bytes: {stdout[:200]!r}"
133
+ )
134
+ case Framing.NDJSON:
135
+ for lineno, line in enumerate(stdout.splitlines(), start=1):
136
+ if not line.strip():
137
+ continue
138
+ try:
139
+ json.loads(line)
140
+ except json.JSONDecodeError:
141
+ violations.append(
142
+ f"NDJSON line {lineno} is not JSON: {line[:200]!r}"
143
+ )
144
+ return violations
145
+
146
+
147
+ def run_error_case(case: ErrorCase) -> list[str]:
148
+ """Contract §2 + §3: nonzero exit, envelope on stdout, no leaks."""
149
+ env = dict(case.env)
150
+ for name in case.secret_env_names:
151
+ env[name] = SENTINEL_SECRET
152
+ proc = _run(case.argv, env, case.unset_env)
153
+ violations: list[str] = []
154
+ if proc.returncode == 0:
155
+ violations.append(
156
+ "exit 0 on a documented error path (the fail-open class); "
157
+ f"stdout: {proc.stdout[:200]!r} stderr: {proc.stderr[:200]!r}"
158
+ )
159
+ if proc.returncode == 2:
160
+ violations.append("exit 2 (usage) on an operational error path")
161
+ if _TRACEBACK_MARKER in proc.stderr or _TRACEBACK_MARKER in proc.stdout:
162
+ violations.append("traceback on a documented error path")
163
+ if case.secret_env_names and SENTINEL_SECRET in proc.stdout + proc.stderr:
164
+ violations.append("secret material leaked into error output")
165
+ if case.json_mode:
166
+ stdout = proc.stdout.strip()
167
+ if not stdout:
168
+ violations.append("no envelope on stdout for a --json error path")
169
+ return violations
170
+ try:
171
+ document = json.loads(stdout)
172
+ except json.JSONDecodeError:
173
+ violations.append(
174
+ f"--json error stdout is not JSON: {stdout[:200]!r}"
175
+ )
176
+ return violations
177
+ violations.extend(validate_envelope(document))
178
+ if case.expect_code is not None and not violations:
179
+ actual = document["error"]["code"]
180
+ if actual != case.expect_code:
181
+ violations.append(
182
+ f"error code {actual!r}, expected {case.expect_code!r}"
183
+ )
184
+ return violations
185
+
186
+
187
+ def run_usage_case(case: UsageCase) -> list[str]:
188
+ """Contract §2: malformed invocations exit 2, without traceback."""
189
+ proc = _run(case.argv, {})
190
+ violations: list[str] = []
191
+ if proc.returncode != 2:
192
+ violations.append(f"exit {proc.returncode}, expected 2 (usage error)")
193
+ if _TRACEBACK_MARKER in proc.stderr:
194
+ violations.append("traceback on a usage error")
195
+ return violations
196
+
197
+
198
+ def run_broken_pipe_case(case: BrokenPipeCase) -> list[str]:
199
+ """Contract §4: closing stdout early must not produce a traceback."""
200
+ if sys.platform == "win32":
201
+ return [] # SIGPIPE semantics don't apply; nothing to prove here.
202
+ with subprocess.Popen(
203
+ list(case.argv),
204
+ stdout=subprocess.PIPE,
205
+ stderr=subprocess.PIPE,
206
+ env={**os.environ, **case.env},
207
+ ) as proc:
208
+ assert proc.stdout is not None
209
+ proc.stdout.read(1)
210
+ proc.stdout.close()
211
+ stderr = proc.stderr.read() if proc.stderr else b""
212
+ proc.wait(timeout=120)
213
+ if _TRACEBACK_MARKER.encode() in stderr:
214
+ return ["traceback on broken stdout pipe"]
215
+ return []
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-suite-conformance
3
+ Version: 1.0.0
4
+ Summary: CLI contract v1 conformance kit for the agent suite (Plan 018 WI-2 / Plan 019 B1).
5
+ License: MIT
6
+ Keywords: agent-suite,cli-contract,conformance,testing
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+
10
+ # agent-suite-conformance
11
+
12
+ The CLI contract v1 conformance kit for the agent suite (Plan 018 WI-2). One
13
+ centrally versioned, stdlib-only package that every suite
14
+ component depends on as a normal pinned version — so there is exactly one kit,
15
+ never per-repo copies to drift.
16
+
17
+ ## What it provides
18
+
19
+ `agent_suite.conformance` — success/error/usage/broken-pipe case runners and the
20
+ common error-envelope validator that hold each suite CLI to the contract in
21
+ `docs/cli-contract.md`:
22
+
23
+ - stdout under `--json` is exactly one JSON document (or documented NDJSON),
24
+ zero non-JSON bytes;
25
+ - documented error paths exit nonzero with the common error envelope;
26
+ - usage errors exit 2; broken pipe exits without a traceback;
27
+ - error output carries no secret material.
28
+
29
+ ## Use
30
+
31
+ ```toml
32
+ # in a component's dev/test dependencies
33
+ "agent-suite-conformance==1.0.0"
34
+ ```
35
+
36
+ ```python
37
+ from agent_suite.conformance import (
38
+ KIT_VERSION, CLI_CONTRACT_VERSION,
39
+ SuccessCase, ErrorCase, UsageCase,
40
+ run_success_case, run_error_case, run_usage_case,
41
+ validate_envelope,
42
+ )
43
+ ```
44
+
45
+ The kit discovers what to test from each component's CLI manifest
46
+ (`<tool> contract --json`); see the agent-suite CLI contract for the manifest
47
+ shape.
48
+
49
+ ## Provenance
50
+
51
+ Built from the single source of truth at `src/agent_suite/conformance/` in the
52
+ agent-suite repository. `version` here equals `agent_suite.conformance.KIT_VERSION`;
53
+ a guard test fails CI if they diverge.
@@ -0,0 +1,6 @@
1
+ agent_suite/conformance/__init__.py,sha256=tLw3hDbCRttlWMiaChqtCDjJ0jXwc6lp18r8zsd8S1o,1150
2
+ agent_suite/conformance/envelope.py,sha256=efpArYbwx4TjjwZzg5gvlZd57ME_lv8XOntGyQYI9sY,4043
3
+ agent_suite/conformance/kit.py,sha256=j-FlIHj4reinUQIPVAELAkSpUr9kENPA_UelrBnrIjI,7289
4
+ agent_suite_conformance-1.0.0.dist-info/METADATA,sha256=RjpJmt88SbNZpmZx8ekXrmo_qObHHIYu1bG6qyfuk-Y,1724
5
+ agent_suite_conformance-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ agent_suite_conformance-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any