api-shakedown 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 (40) hide show
  1. api_shakedown/__init__.py +3 -0
  2. api_shakedown/__main__.py +3 -0
  3. api_shakedown/agents/__init__.py +1 -0
  4. api_shakedown/agents/_common.py +36 -0
  5. api_shakedown/agents/chaos.py +146 -0
  6. api_shakedown/agents/contracts.py +176 -0
  7. api_shakedown/agents/coordinator.py +211 -0
  8. api_shakedown/agents/reviewer.py +301 -0
  9. api_shakedown/agents/stubs.py +163 -0
  10. api_shakedown/agents/testdata.py +146 -0
  11. api_shakedown/chaos.py +451 -0
  12. api_shakedown/chaos_control.py +54 -0
  13. api_shakedown/cli.py +131 -0
  14. api_shakedown/config.py +19 -0
  15. api_shakedown/contracts.py +243 -0
  16. api_shakedown/harness/__init__.py +26 -0
  17. api_shakedown/mcp_server.py +218 -0
  18. api_shakedown/pipeline.py +92 -0
  19. api_shakedown/py.typed +0 -0
  20. api_shakedown/spec/__init__.py +17 -0
  21. api_shakedown/spec/chunking.py +32 -0
  22. api_shakedown/spec/edge_cases.py +149 -0
  23. api_shakedown/spec/loader.py +104 -0
  24. api_shakedown/spec/models.py +78 -0
  25. api_shakedown/spec/normalize.py +175 -0
  26. api_shakedown/spec/refs.py +59 -0
  27. api_shakedown/spec/samples.py +100 -0
  28. api_shakedown/spec/schema_check.py +31 -0
  29. api_shakedown/templates/__init__.py +1 -0
  30. api_shakedown/templates/chaos_cli.py +92 -0
  31. api_shakedown/templates/contract_runner.py +50 -0
  32. api_shakedown/templates/robustness_runner.py +37 -0
  33. api_shakedown/templates/shakedown_support.py +92 -0
  34. api_shakedown/testdata.py +215 -0
  35. api_shakedown/wiremock.py +273 -0
  36. api_shakedown-0.1.0.dist-info/METADATA +431 -0
  37. api_shakedown-0.1.0.dist-info/RECORD +40 -0
  38. api_shakedown-0.1.0.dist-info/WHEEL +4 -0
  39. api_shakedown-0.1.0.dist-info/entry_points.txt +3 -0
  40. api_shakedown-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """API Shakedown: OpenAPI spec in, ready-to-run chaos test harness out."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from api_shakedown.cli import app
2
+
3
+ app()
@@ -0,0 +1 @@
1
+ """Specialist agents for API Shakedown."""
@@ -0,0 +1,36 @@
1
+ """Shared plumbing for the shakedown specialists."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import TypeVar
5
+
6
+ from agentpanel.structured import StructuredOutputError, ainvoke_structured
7
+ from langchain_core.language_models import BaseChatModel
8
+ from langchain_core.messages import HumanMessage, SystemMessage
9
+ from pydantic import BaseModel
10
+
11
+ T = TypeVar("T", bound=BaseModel)
12
+
13
+
14
+ async def generate_checked(
15
+ model: BaseChatModel,
16
+ instructions: str,
17
+ prompt: str,
18
+ schema: type[T],
19
+ *,
20
+ check: Callable[[T], list[str]],
21
+ max_attempts: int,
22
+ notes: list[str],
23
+ ) -> T | None:
24
+ """Structured generation that never raises on bad output.
25
+
26
+ Returns the passing reply, or the last reply that parsed (so callers can keep its
27
+ valid parts), or None. Problems are recorded in `notes`.
28
+ """
29
+ messages = [SystemMessage(instructions), HumanMessage(prompt)]
30
+ try:
31
+ return await ainvoke_structured(
32
+ model, messages, schema, max_attempts=max_attempts, check=check
33
+ )
34
+ except StructuredOutputError as exc:
35
+ notes.append(f"Some LLM output was unusable: {exc}")
36
+ return exc.value if isinstance(exc.value, schema) else None
@@ -0,0 +1,146 @@
1
+ """Agent that invents failure scenarios: timeouts, malformed payloads, partial outages.
2
+
3
+ Baseline scenarios and robustness probes come from code. The LLM adds scenarios aimed at
4
+ this particular API (which calls are critical, which can be retried, where partial
5
+ failure hurts most), expressed in the constrained `ScenarioProposal` format and checked
6
+ before being compiled to WireMock mappings.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from agentpanel import AgentContext
12
+ from agentpanel.llm_agents import render_feedback
13
+ from langchain_core.language_models import BaseChatModel
14
+ from pydantic import BaseModel
15
+
16
+ from api_shakedown.agents._common import generate_checked
17
+ from api_shakedown.chaos import (
18
+ ChaosSuite,
19
+ ScenarioProposal,
20
+ baseline_scenarios,
21
+ build_probes,
22
+ compile_scenario,
23
+ scenario_problems,
24
+ )
25
+ from api_shakedown.spec import ApiSpec
26
+ from api_shakedown.testdata import Dataset
27
+ from api_shakedown.wiremock import DEFAULT_STUB_URL, base_path
28
+
29
+ MAX_LLM_SCENARIOS = 10
30
+
31
+ INSTRUCTIONS = """\
32
+ You are a chaos engineer. You design failure scenarios, injected into a mock of an API, \
33
+ that test how CLIENTS of that API cope.
34
+
35
+ Propose scenarios specific to this API, for example:
36
+ - partial outages: writes fail while reads still work, or one critical endpoint is down;
37
+ - a slow dependency on the most-used read;
38
+ - a create call that fails twice then succeeds (tests retries and idempotency);
39
+ - schema drift (wrong_types, missing_required) in an important response;
40
+ - rate limiting on list endpoints.
41
+
42
+ Rules:
43
+ - Do not repeat the baseline scenarios listed in the prompt.
44
+ - Each effect uses: delay_ms, fault, status (400-599), or malformation. fault cannot be \
45
+ combined with status or malformation; status and malformation are exclusive.
46
+ - operations must list the affected operation ids (never empty: the baseline already \
47
+ covers every operation). A scenario about one endpoint lists only that endpoint.
48
+ - Use "times" for failures that recover after N calls; null means always while active. \
49
+ A flaky scenario must set times.
50
+ - Match effect to category: latency/timeout need delay_ms; rate_limit uses status 429; \
51
+ flaky, outage and partial_outage use a 5xx/429 status or a fault (a 4xx is not a \
52
+ transient failure); a partial_outage leaves some operations working; malformed_response \
53
+ needs a malformation.
54
+ - expectation says concretely what a resilient client should do."""
55
+
56
+
57
+ class ScenarioProposals(BaseModel):
58
+ scenarios: list[ScenarioProposal]
59
+
60
+
61
+ @dataclass
62
+ class ChaosAgent:
63
+ model: BaseChatModel | None = None
64
+ name: str = "chaos"
65
+ kind: str = "chaos scenarios and robustness probes"
66
+ max_attempts: int = 3
67
+
68
+ async def produce(self, ctx: AgentContext) -> ChaosSuite:
69
+ spec: ApiSpec = ctx.inputs["spec"]
70
+ dataset: Dataset | None = ctx.inputs.get("dataset")
71
+ notes: list[str] = []
72
+ baseline = baseline_scenarios(spec)
73
+ proposals: list[ScenarioProposal] = []
74
+ if self.model is not None:
75
+ feedback = render_feedback(ctx.feedback_for(self.name))
76
+ proposals = await self._generate(self.model, spec, baseline, feedback, notes)
77
+
78
+ scenarios = [compile_scenario(spec, p, "baseline", dataset) for p in baseline]
79
+ scenarios += [compile_scenario(spec, p, "llm", dataset) for p in proposals]
80
+ return ChaosSuite(
81
+ title=spec.title,
82
+ version=spec.version,
83
+ default_target=DEFAULT_STUB_URL + base_path(spec),
84
+ scenarios=scenarios,
85
+ probes=build_probes(spec, dataset),
86
+ notes=notes,
87
+ )
88
+
89
+ async def _generate(
90
+ self,
91
+ model: BaseChatModel,
92
+ spec: ApiSpec,
93
+ baseline: list[ScenarioProposal],
94
+ feedback: str,
95
+ notes: list[str],
96
+ ) -> list[ScenarioProposal]:
97
+ taken = {p.name for p in baseline}
98
+ output = await generate_checked(
99
+ model,
100
+ INSTRUCTIONS,
101
+ _prompt(spec, baseline, feedback),
102
+ ScenarioProposals,
103
+ check=lambda out: _problems(spec, taken, out),
104
+ max_attempts=self.max_attempts,
105
+ notes=notes,
106
+ )
107
+ usable: list[ScenarioProposal] = []
108
+ for proposal in output.scenarios if output else []:
109
+ if proposal.name in taken or scenario_problems(spec, proposal):
110
+ continue
111
+ taken.add(proposal.name)
112
+ usable.append(proposal)
113
+ return usable[:MAX_LLM_SCENARIOS]
114
+
115
+
116
+ def _prompt(spec: ApiSpec, baseline: list[ScenarioProposal], feedback: str) -> str:
117
+ operations = "\n".join(
118
+ f"- {op.id}: {op.method.upper()} {op.path}"
119
+ + (f" ({op.summary})" if op.summary else "")
120
+ + f" statuses {[r.status for r in op.responses]}"
121
+ for op in spec.operations
122
+ )
123
+ existing = "\n".join(f"- {p.name}: {p.title}" for p in baseline)
124
+ prompt = (
125
+ f"API: {spec.title} {spec.version}\n\nOperations:\n{operations}\n\n"
126
+ f"Baseline scenarios (already included, do not repeat):\n{existing}\n\n"
127
+ "Propose 3 to 6 additional scenarios targeted at this API."
128
+ )
129
+ if feedback:
130
+ prompt += (
131
+ f"\n\nReviewers rejected the previous scenarios. Address this feedback:\n{feedback}"
132
+ )
133
+ return prompt
134
+
135
+
136
+ def _problems(spec: ApiSpec, taken: set[str], output: ScenarioProposals) -> list[str]:
137
+ problems = []
138
+ seen = set(taken)
139
+ if not output.scenarios:
140
+ problems.append("Propose at least one scenario.")
141
+ for proposal in output.scenarios:
142
+ if proposal.name in seen:
143
+ problems.append(f"scenario {proposal.name}: duplicate or baseline name.")
144
+ seen.add(proposal.name)
145
+ problems += [f"scenario {proposal.name}: {p}" for p in scenario_problems(spec, proposal)]
146
+ return problems
@@ -0,0 +1,176 @@
1
+ """Agent that writes contract tests against the spec.
2
+
3
+ The LLM proposes test cases as data; code validates each against the spec, feeds problems
4
+ back, and guarantees a happy-path case per operation (falling back to a schema-derived
5
+ case). The cases run under a fixed pytest runner, so no generated code is executed.
6
+ Without a model, the agent emits only the deterministic happy-path cases.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from agentpanel import AgentContext
12
+ from agentpanel.llm_agents import render_feedback
13
+ from langchain_core.language_models import BaseChatModel
14
+ from pydantic import BaseModel
15
+
16
+ from api_shakedown.agents._common import generate_checked
17
+ from api_shakedown.contracts import (
18
+ CaseProposal,
19
+ ContractSuite,
20
+ build_suite,
21
+ happy_proposal,
22
+ proposal_problems,
23
+ )
24
+ from api_shakedown.spec import ApiSpec, Operation, chunk_operations
25
+ from api_shakedown.spec.chunking import DEFAULT_MAX_CHARS
26
+ from api_shakedown.testdata import Dataset
27
+
28
+ MAX_CASES_PER_OPERATION = 8
29
+
30
+ INSTRUCTIONS = """\
31
+ You design HTTP contract test cases from an OpenAPI description.
32
+
33
+ For each operation, propose:
34
+ - one happy-path case with realistic, valid inputs;
35
+ - one case per documented error status where the inputs would plausibly cause it, \
36
+ e.g. a well-formed but unknown id for 404, or a body missing a required field for 400;
37
+ - boundary cases for constrained inputs (minimum/maximum values, enum members).
38
+
39
+ Rules:
40
+ - expected_status must be one of the operation's documented statuses.
41
+ - Path parameters must always be valid values for their schema, even in error cases, \
42
+ so the URL still routes.
43
+ - Always include required query parameters.
44
+ - Success cases must send inputs that satisfy every schema.
45
+ - Never add authentication headers; they are added automatically.
46
+ - Give each case a short snake_case name describing what it checks."""
47
+
48
+
49
+ class CaseProposals(BaseModel):
50
+ cases: list[CaseProposal]
51
+
52
+
53
+ @dataclass
54
+ class ContractTestAgent:
55
+ model: BaseChatModel | None = None
56
+ name: str = "contracts"
57
+ kind: str = "contract test suite"
58
+ max_chars: int = DEFAULT_MAX_CHARS
59
+ max_attempts: int = 3
60
+
61
+ async def produce(self, ctx: AgentContext) -> ContractSuite:
62
+ spec: ApiSpec = ctx.inputs["spec"]
63
+ dataset: Dataset | None = ctx.inputs.get("dataset")
64
+ notes: list[str] = []
65
+ proposals: list[CaseProposal] = []
66
+ if self.model is not None:
67
+ feedback = render_feedback(ctx.feedback_for(self.name))
68
+ for chunk in chunk_operations(spec.operations, self.max_chars):
69
+ proposals += await self._generate(self.model, spec, dataset, chunk, feedback, notes)
70
+ cases = _with_happy_paths(spec, dataset, proposals, notes, self.model)
71
+ return build_suite(spec, cases, notes)
72
+
73
+ async def _generate(
74
+ self,
75
+ model: BaseChatModel,
76
+ spec: ApiSpec,
77
+ dataset: Dataset | None,
78
+ chunk: list[Operation],
79
+ feedback: str,
80
+ notes: list[str],
81
+ ) -> list[CaseProposal]:
82
+ ops = {op.id: op for op in chunk}
83
+ output = await generate_checked(
84
+ model,
85
+ INSTRUCTIONS,
86
+ _prompt(spec, dataset, chunk, feedback),
87
+ CaseProposals,
88
+ check=lambda out: _problems(spec, dataset, ops, out),
89
+ max_attempts=self.max_attempts,
90
+ notes=notes,
91
+ )
92
+ usable: list[CaseProposal] = []
93
+ per_op: dict[str, int] = {}
94
+ for proposal in output.cases if output else []:
95
+ op = ops.get(proposal.operation_id)
96
+ if op is None or proposal_problems(spec, op, proposal, dataset):
97
+ continue
98
+ if per_op.get(op.id, 0) < MAX_CASES_PER_OPERATION:
99
+ per_op[op.id] = per_op.get(op.id, 0) + 1
100
+ usable.append(proposal)
101
+ return usable
102
+
103
+
104
+ def _with_happy_paths(
105
+ spec: ApiSpec,
106
+ dataset: Dataset | None,
107
+ proposals: list[CaseProposal],
108
+ notes: list[str],
109
+ model: BaseChatModel | None,
110
+ ) -> list[CaseProposal]:
111
+ covered = {p.operation_id for p in proposals if p.expected_status.startswith("2")}
112
+ result = list(proposals)
113
+ for op in spec.operations:
114
+ if op.id in covered:
115
+ continue
116
+ fallback = happy_proposal(op, dataset)
117
+ if fallback is not None:
118
+ result.append(fallback)
119
+ if model is not None:
120
+ notes.append(f"{op.id}: used a schema-generated happy-path case.")
121
+ order = {op.id: i for i, op in enumerate(spec.operations)}
122
+ return sorted(result, key=lambda p: order[p.operation_id]) # stable within an operation
123
+
124
+
125
+ def _prompt(spec: ApiSpec, dataset: Dataset | None, chunk: list[Operation], feedback: str) -> str:
126
+ statuses = "\n".join(
127
+ f"- {op.id}: documented statuses {[r.status for r in op.responses]}" for op in chunk
128
+ )
129
+ prompt = (
130
+ f"API: {spec.title} {spec.version}\n\n"
131
+ "<operations>\n" + "\n".join(op.to_prompt() for op in chunk) + "\n</operations>\n\n"
132
+ f"Propose test cases for these operations:\n{statuses}"
133
+ )
134
+ known = _known_ids(dataset, chunk)
135
+ if known:
136
+ prompt += (
137
+ "\n\nThese ids exist in the test data. Success cases must use one of them; "
138
+ "404 cases must use a well-formed id that is NOT listed:\n" + "\n".join(known)
139
+ )
140
+ if feedback:
141
+ prompt += f"\n\nReviewers rejected the previous tests. Address this feedback:\n{feedback}"
142
+ return prompt
143
+
144
+
145
+ def _known_ids(dataset: Dataset | None, chunk: list[Operation]) -> list[str]:
146
+ lines = []
147
+ for op in chunk:
148
+ for param in (p for p in op.parameters if p.location == "path"):
149
+ ids = dataset.known_ids(op.path, param.name) if dataset else None
150
+ if ids:
151
+ lines.append(f"- {op.id} {param.name}: {ids}")
152
+ return lines
153
+
154
+
155
+ def _problems(
156
+ spec: ApiSpec, dataset: Dataset | None, ops: dict[str, Operation], output: CaseProposals
157
+ ) -> list[str]:
158
+ problems = []
159
+ for proposal in output.cases:
160
+ label = f"case {proposal.operation_id}/{proposal.name}"
161
+ op = ops.get(proposal.operation_id)
162
+ if op is None:
163
+ problems.append(f"{label}: unknown operation_id; use one of {sorted(ops)}.")
164
+ continue
165
+ problems += [f"{label}: {p}" for p in proposal_problems(spec, op, proposal, dataset)]
166
+ happy = {
167
+ p.operation_id
168
+ for p in output.cases
169
+ if p.expected_status.startswith("2")
170
+ and p.operation_id in ops
171
+ and not proposal_problems(spec, ops[p.operation_id], p, dataset)
172
+ }
173
+ problems += [
174
+ f"No valid happy-path (2xx) case for {op_id}." for op_id in ops if op_id not in happy
175
+ ]
176
+ return problems
@@ -0,0 +1,211 @@
1
+ """Coordinator agent that assembles reviewed artifacts into the final harness.
2
+
3
+ Assembly is deterministic: every file comes from an artifact or a fixed template, and the
4
+ README is generated from what is actually in the harness. Review outcomes are reported,
5
+ not hidden: anything still rejected when the revision budget ran out is listed.
6
+ """
7
+
8
+ import json
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from agentpanel import AgentContext, Review
14
+ from pydantic import BaseModel, Field
15
+
16
+ from api_shakedown.chaos import CHAOS_CLI_FILE, SCENARIOS_FILE, ChaosSuite
17
+ from api_shakedown.contracts import ContractSuite
18
+ from api_shakedown.spec import ApiSpec
19
+ from api_shakedown.testdata import Dataset
20
+ from api_shakedown.wiremock import DEFAULT_STUB_URL, STATUS_HEADER, StubSet, base_path
21
+
22
+ WIREMOCK_IMAGE = "wiremock/wiremock:3.13.1"
23
+ TEST_REQUIREMENTS = ["pytest>=8", "httpx>=0.27", "openapi-schema-validator>=0.6"]
24
+
25
+
26
+ class Harness(BaseModel):
27
+ title: str
28
+ version: str
29
+ files: dict[str, str] # relative path -> content
30
+ summary: dict[str, int]
31
+ notes: list[str] = Field(default_factory=list)
32
+ reviews: list[str] = Field(default_factory=list) # final-round outcome per artifact
33
+ unresolved: list[str] = Field(default_factory=list)
34
+
35
+
36
+ @dataclass
37
+ class HarnessCoordinator:
38
+ generated_at: str = "" # shown in the README; empty keeps output reproducible
39
+
40
+ async def assemble(self, ctx: AgentContext) -> Harness:
41
+ spec: ApiSpec = ctx.inputs["spec"]
42
+ dataset: Dataset | None = ctx.inputs.get("dataset")
43
+ stubs: StubSet = ctx.artifacts["stubs"].content
44
+ contracts: ContractSuite = ctx.artifacts["contracts"].content
45
+ chaos: ChaosSuite = ctx.artifacts["chaos"].content
46
+ return build_harness(
47
+ spec, dataset, stubs, contracts, chaos, ctx.reviews, ctx.unresolved, self.generated_at
48
+ )
49
+
50
+
51
+ def build_harness(
52
+ spec: ApiSpec,
53
+ dataset: Dataset | None,
54
+ stubs: StubSet,
55
+ contracts: ContractSuite,
56
+ chaos: ChaosSuite,
57
+ reviews: Sequence[Review] = (),
58
+ unresolved: list[str] | None = None,
59
+ generated_at: str = "",
60
+ ) -> Harness:
61
+ files: dict[str, str] = {
62
+ "docker-compose.yml": _compose(),
63
+ "wiremock/mappings/stubs.json": _json(stubs.to_wiremock()),
64
+ "requirements.txt": "\n".join(TEST_REQUIREMENTS) + "\n",
65
+ "tests/pytest.ini": "[pytest]\n",
66
+ }
67
+ files |= {f"tests/{name}": content for name, content in contracts.files().items()}
68
+ for name, content in chaos.files().items():
69
+ folder = "chaos" if name in (CHAOS_CLI_FILE, SCENARIOS_FILE) else "tests"
70
+ files[f"{folder}/{name}"] = content
71
+ if dataset is not None:
72
+ files["data/dataset.json"] = _json(dataset.model_dump(by_alias=True))
73
+
74
+ final_reviews = _final_reviews(reviews)
75
+ summary = {
76
+ "operations": len(spec.operations),
77
+ "stub_mappings": len(stubs.mappings),
78
+ "contract_cases": len(contracts.cases),
79
+ "chaos_scenarios": len(chaos.scenarios),
80
+ "robustness_probes": len(chaos.probes),
81
+ "records": sum(len(e.records) for e in dataset.entities.values()) if dataset else 0,
82
+ }
83
+ notes = [
84
+ *(f"testdata: {n}" for n in (dataset.notes if dataset else [])),
85
+ *(f"stubs: {n}" for n in stubs.notes),
86
+ *(f"contracts: {n}" for n in contracts.notes),
87
+ *(f"chaos: {n}" for n in chaos.notes),
88
+ ]
89
+ review_lines = [
90
+ f"{r.target}: {'approved' if r.approved else 'REJECTED'}"
91
+ + "".join(f"\n - {c}" for c in r.comments)
92
+ for r in final_reviews
93
+ ]
94
+ harness = Harness(
95
+ title=spec.title,
96
+ version=spec.version,
97
+ files=files,
98
+ summary=summary,
99
+ notes=notes,
100
+ reviews=review_lines,
101
+ unresolved=list(unresolved or []),
102
+ )
103
+ harness.files["README.md"] = _readme(spec, dataset, chaos, harness, generated_at)
104
+ harness.files["shakedown.json"] = _json(
105
+ harness.model_dump(exclude={"files"}) | {"generated_at": generated_at or None}
106
+ )
107
+ return harness
108
+
109
+
110
+ def _final_reviews(reviews: Sequence[Review]) -> list[Review]:
111
+ """Each artifact's latest review (merging comments from all its reviewers)."""
112
+ latest: dict[str, Review] = {}
113
+ for review in reviews:
114
+ current = latest.get(review.target)
115
+ if current is None or review.round > current.round:
116
+ latest[review.target] = review
117
+ elif review.round == current.round:
118
+ latest[review.target] = current.model_copy(
119
+ update={
120
+ "approved": current.approved and review.approved,
121
+ "comments": [*current.comments, *review.comments],
122
+ }
123
+ )
124
+ return [latest[k] for k in sorted(latest)]
125
+
126
+
127
+ def _compose() -> str:
128
+ return f"""\
129
+ # WireMock serving the generated stubs. Port: SHAKEDOWN_WIREMOCK_PORT (default 8080).
130
+ services:
131
+ wiremock:
132
+ image: {WIREMOCK_IMAGE}
133
+ ports:
134
+ - "${{SHAKEDOWN_WIREMOCK_PORT:-8080}}:8080"
135
+ volumes:
136
+ - ./wiremock:/home/wiremock
137
+ """
138
+
139
+
140
+ def _readme(
141
+ spec: ApiSpec,
142
+ dataset: Dataset | None,
143
+ chaos: ChaosSuite,
144
+ harness: Harness,
145
+ generated_at: str,
146
+ ) -> str:
147
+ target = DEFAULT_STUB_URL + base_path(spec)
148
+ s = harness.summary
149
+ when = f" on {generated_at}" if generated_at else ""
150
+ scenario_rows = "\n".join(
151
+ f"| `{sc.id}` | {sc.category} | {sc.title} | {sc.expectation} |" for sc in chaos.scenarios
152
+ )
153
+ sections = [
154
+ f"# Chaos test harness: {spec.title} {spec.version}",
155
+ f"Generated by API Shakedown{when} from the OpenAPI spec.",
156
+ "## Quick start",
157
+ "```bash\n"
158
+ "docker compose up -d # WireMock with the stubs on :8080\n"
159
+ "pip install -r requirements.txt\n\n"
160
+ "# Contract tests against the stubs (the header asks the stubs for each status)\n"
161
+ "SHAKEDOWN_SELECT_STATUS=1 pytest tests/test_contract.py\n\n"
162
+ "# Switch failure scenarios on and off while your client talks to the stubs\n"
163
+ "python chaos/chaos.py list\n"
164
+ "python chaos/chaos.py on flaky-start\n"
165
+ "python chaos/chaos.py off\n\n"
166
+ "# Against a real deployment: contract tests and robustness probes\n"
167
+ "SHAKEDOWN_TARGET_URL=https://api.example.com/v1 pytest tests\n"
168
+ "```",
169
+ 'PowerShell: `$env:SHAKEDOWN_SELECT_STATUS = "1"; pytest tests/test_contract.py`.',
170
+ f"The stubs serve `{target}`. Use another port with `SHAKEDOWN_WIREMOCK_PORT`, then "
171
+ "point `SHAKEDOWN_TARGET_URL` and `chaos.py --wiremock` at it.",
172
+ "## What's inside",
173
+ "| | |\n|---|---|\n"
174
+ f"| `wiremock/mappings/` | {s['stub_mappings']} stub mappings for {s['operations']} "
175
+ "operations |\n"
176
+ f"| `tests/test_contract.py` | {s['contract_cases']} contract test cases |\n"
177
+ f"| `tests/test_robustness.py` | {s['robustness_probes']} malformed-request probes "
178
+ "(expect 4xx, never 5xx) |\n"
179
+ f"| `chaos/chaos.py` | {s['chaos_scenarios']} failure scenarios to inject into the "
180
+ "stubs |\n"
181
+ + (
182
+ f"| `data/dataset.json` | {s['records']} test records and edge-case values |\n"
183
+ if dataset
184
+ else ""
185
+ ),
186
+ "## How the stubs behave",
187
+ "- Each operation returns its success response; with test data, known ids return "
188
+ "their record and unknown ids return 404.\n"
189
+ f"- Any other documented response is returned when the request carries "
190
+ f"`{STATUS_HEADER}: <status>`, e.g. `{STATUS_HEADER}: 404`.\n"
191
+ "- An active chaos scenario overrides everything until `chaos.py off`.",
192
+ "## Chaos scenarios",
193
+ "| Scenario | Category | What happens | A resilient client should |\n"
194
+ "|---|---|---|---|\n" + scenario_rows,
195
+ ]
196
+ if harness.reviews or harness.unresolved:
197
+ review = "\n".join(f"- {line}" for line in harness.reviews)
198
+ if harness.unresolved:
199
+ review += (
200
+ "\n\nStill rejected when the revision budget ran out: "
201
+ + ", ".join(harness.unresolved)
202
+ + ". Check those parts before relying on them."
203
+ )
204
+ sections += ["## Review", review]
205
+ if harness.notes:
206
+ sections += ["## Generation notes", "\n".join(f"- {n}" for n in harness.notes)]
207
+ return "\n\n".join(sections) + "\n"
208
+
209
+
210
+ def _json(value: Any) -> str:
211
+ return json.dumps(value, indent=2, ensure_ascii=False, default=str) + "\n"