specrig 0.2.0__tar.gz → 0.3.1__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.
Files changed (141) hide show
  1. {specrig-0.2.0 → specrig-0.3.1}/PKG-INFO +30 -6
  2. {specrig-0.2.0 → specrig-0.3.1}/README.md +29 -5
  3. {specrig-0.2.0 → specrig-0.3.1}/docs/design.md +20 -2
  4. {specrig-0.2.0 → specrig-0.3.1}/pyproject.toml +2 -1
  5. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/specrig.md +16 -4
  6. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/specrig.md +16 -4
  7. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/__init__.py +2 -2
  8. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/backend.py +58 -17
  9. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/postgres.py +108 -4
  10. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/templates.py +15 -3
  11. specrig-0.3.1/tests/test_backend.py +295 -0
  12. {specrig-0.2.0 → specrig-0.3.1}/tests/test_harness.py +131 -0
  13. {specrig-0.2.0 → specrig-0.3.1}/tests/test_postgres.py +206 -1
  14. specrig-0.2.0/tests/test_backend.py +0 -109
  15. {specrig-0.2.0 → specrig-0.3.1}/.github/workflows/release.yml +0 -0
  16. {specrig-0.2.0 → specrig-0.3.1}/.gitignore +0 -0
  17. {specrig-0.2.0 → specrig-0.3.1}/LICENSE +0 -0
  18. {specrig-0.2.0 → specrig-0.3.1}/Makefile +0 -0
  19. {specrig-0.2.0 → specrig-0.3.1}/samples/docs/billing.yaml +0 -0
  20. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/AGENTS.md +0 -0
  21. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/CLAUDE.md +0 -0
  22. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/config/__init__.py +0 -0
  23. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/config/settings.py +0 -0
  24. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/config/urls.py +0 -0
  25. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/config/wsgi.py +0 -0
  26. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/manage.py +0 -0
  27. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/pyproject.toml +0 -0
  28. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/__init__.py +0 -0
  29. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/billing.py +0 -0
  30. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/jobs.py +0 -0
  31. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/management/__init__.py +0 -0
  32. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/management/commands/__init__.py +0 -0
  33. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/management/commands/remind.py +0 -0
  34. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/migrations/0001_initial.py +0 -0
  35. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/migrations/__init__.py +0 -0
  36. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/models.py +0 -0
  37. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/views.py +0 -0
  38. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/specrig.yaml +0 -0
  39. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/checks.yaml +0 -0
  40. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/features/history.feature +0 -0
  41. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/features/reminders.feature +0 -0
  42. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/flows.yaml +0 -0
  43. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/internal/boot.feature +0 -0
  44. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/internal/harness.py +0 -0
  45. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/internal/test_cases.py +0 -0
  46. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/steps/history.py +0 -0
  47. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/steps/reminders.py +0 -0
  48. {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/surface.yaml +0 -0
  49. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/AGENTS.md +0 -0
  50. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/CLAUDE.md +0 -0
  51. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/alembic.ini +0 -0
  52. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/migrations/env.py +0 -0
  53. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/migrations/versions/0001_orders.py +0 -0
  54. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/pyproject.toml +0 -0
  55. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/__init__.py +0 -0
  56. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/billing.py +0 -0
  57. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/cli.py +0 -0
  58. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/db.py +0 -0
  59. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/jobs.py +0 -0
  60. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/main.py +0 -0
  61. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/settings.py +0 -0
  62. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/specrig.yaml +0 -0
  63. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/checks.yaml +0 -0
  64. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/features/orders.feature +0 -0
  65. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/features/placing.feature +0 -0
  66. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/flows.yaml +0 -0
  67. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/internal/boot.feature +0 -0
  68. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/internal/harness.py +0 -0
  69. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/internal/test_cases.py +0 -0
  70. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/steps/orders.py +0 -0
  71. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/steps/placing.py +0 -0
  72. {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/surface.yaml +0 -0
  73. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/__main__.py +0 -0
  74. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/_yaml.py +0 -0
  75. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/asgi.py +0 -0
  76. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/clock.py +0 -0
  77. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/__init__.py +0 -0
  78. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/calls.py +0 -0
  79. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/django.py +0 -0
  80. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/fastapi.py +0 -0
  81. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/index.py +0 -0
  82. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/settings.py +0 -0
  83. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/contract.py +0 -0
  84. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/django/__init__.py +0 -0
  85. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/docker.py +0 -0
  86. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/fastapi/__init__.py +0 -0
  87. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/flows.py +0 -0
  88. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/gate.py +0 -0
  89. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/http.py +0 -0
  90. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/contributing.md +0 -0
  91. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/header.md +0 -0
  92. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/reference.md +0 -0
  93. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/rule11.md +0 -0
  94. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/short.md +0 -0
  95. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/modes.yaml +0 -0
  96. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/proxy.py +0 -0
  97. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/py.typed +0 -0
  98. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/records.py +0 -0
  99. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/redis.py +0 -0
  100. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/resource.py +0 -0
  101. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/rig.py +0 -0
  102. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/run.py +0 -0
  103. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/standins.py +0 -0
  104. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/suite.py +0 -0
  105. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/surface.py +0 -0
  106. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/__init__.py +0 -0
  107. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/agents.py +0 -0
  108. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/cli.py +0 -0
  109. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/init.py +0 -0
  110. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/modes.py +0 -0
  111. {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/service.py +0 -0
  112. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/manage.py +0 -0
  113. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/pyproject.toml +0 -0
  114. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/__init__.py +0 -0
  115. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/api.py +0 -0
  116. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/settings.py +0 -0
  117. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/urls.py +0 -0
  118. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/views.py +0 -0
  119. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/warehouse.py +0 -0
  120. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/.env.example +0 -0
  121. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/__init__.py +0 -0
  122. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/config.py +0 -0
  123. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/crm.py +0 -0
  124. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/main.py +0 -0
  125. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/routes/__init__.py +0 -0
  126. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/routes/orders.py +0 -0
  127. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/sessions.py +0 -0
  128. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/pyproject.toml +0 -0
  129. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/agents/verdicts.yaml +0 -0
  130. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/markers/areas.yaml +0 -0
  131. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/records/reminders.flows.yaml +0 -0
  132. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/records/reminders.trace.json +0 -0
  133. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/records/shop.flows.yaml +0 -0
  134. {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/records/shop.trace.json +0 -0
  135. {specrig-0.2.0 → specrig-0.3.1}/tests/test_agents.py +0 -0
  136. {specrig-0.2.0 → specrig-0.3.1}/tests/test_flows.py +0 -0
  137. {specrig-0.2.0 → specrig-0.3.1}/tests/test_format.py +0 -0
  138. {specrig-0.2.0 → specrig-0.3.1}/tests/test_gate.py +0 -0
  139. {specrig-0.2.0 → specrig-0.3.1}/tests/test_init.py +0 -0
  140. {specrig-0.2.0 → specrig-0.3.1}/tests/test_rules.py +0 -0
  141. {specrig-0.2.0 → specrig-0.3.1}/tests/test_surface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: specrig
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: A harness for a FastAPI or Django service developed specification first
5
5
  Project-URL: Homepage, https://github.com/specrig/specrig-python
6
6
  Project-URL: Protocol, https://github.com/specrig/protocol
@@ -241,10 +241,12 @@ fixtures, autouse ones included, would change the service the cases see.
241
241
 
242
242
  | | |
243
243
  |---|---|
244
- | `harness.<backend>.reply(method, path, status, body)` | what the neighbour answers |
245
- | `harness.<backend>.reply_to(method, path, query, status, body)` | the same, for a request whose query has these values; the reply naming the most of them wins |
246
- | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once |
247
- | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body |
244
+ | `harness.<backend>.reply(method, path, status, body, headers)` | what the neighbour answers; `headers`, strings to strings, are added to the response the client sees. A segment in braces, `/orders/{id}`, takes any one segment, the exact path wins over a template and the first declared of two templates over the second — in every call here that names a path |
245
+ | `harness.<backend>.reply_to(method, path, query, status, body, headers)` | the same, for a request whose query has these values; the reply naming the most of them wins |
246
+ | `harness.<backend>.reply_with(method, path, answer, headers)` | the answer worked out from each request: `answer` takes what `requests` returns one of and gives `(status, body)`; a reply naming no query, checked as the others |
247
+ | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once; no reply lifts it |
248
+ | `harness.<backend>.heal()` | the neighbour answers again: every `time_out` is lifted, the replies stay |
249
+ | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body; a template returns the requests it takes |
248
250
  | `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
249
251
  | `harness.run(job, *args, **kwargs)` | a job — see below |
250
252
  | `harness.command(name, *args)` | a command of the service's console; returns its exit code, and `harness.output` what it wrote |
@@ -253,6 +255,10 @@ fixtures, autouse ones included, would change the service the cases see.
253
255
  | `harness.logs` | what the service logged, under its real logging configuration |
254
256
  | `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
255
257
  | `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
258
+ | `harness.<postgres>.refuse(table, *writes)` | every insert, update and delete on the table, or those given, fails in Postgres itself, whoever sends it; reads go on working — see Postgres |
259
+ | `harness.<postgres>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
260
+ | `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
261
+ | `harness.<postgres>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
256
262
  | `harness.application` | FastAPI: the app the case runs |
257
263
 
258
264
  The service boots at the first step that reaches it, so a `Given` declares and
@@ -269,7 +275,8 @@ every request and every answer is checked against the neighbour's contract: an
269
275
  unknown route or method, an undocumented status, or a body of the wrong shape
270
276
  fails it. So does a request the service sends past the stand-ins — through
271
277
  `urllib`, or `aiohttp` reading the proxy from the environment —, which the
272
- harness's proxy refused.
278
+ harness's proxy refused. A path template in a step changes nothing there: the
279
+ check and the records start from the path the request came with.
273
280
 
274
281
  A step's request returns when the service has answered it and done everything
275
282
  the request made it do, background tasks included: nothing is in flight between
@@ -396,6 +403,23 @@ with it — through SQLAlchemy's events, or Django's execute wrappers. A fault a
396
403
  step sets before the boot waits for the service to start, so the migration and
397
404
  the start go through; the next case starts whole.
398
405
 
406
+ `harness.database.refuse("orders")` makes Postgres refuse one table's writes —
407
+ every insert, update and delete, or only those named, `Write.INSERT`,
408
+ `Write.UPDATE`, `Write.DELETE` — with `specrig: insert on orders is refused`. A
409
+ trigger refuses, not the service's driver, so every connection meets it, the
410
+ case's own included, and a write in `RETURNING` or a CTE as well as a plain one;
411
+ reads and the other tables go on working, so a case can check what the failed
412
+ write left behind. The table is `orders` or `schema.orders`. `refuse_after("orders",
413
+ 1)` lets that many rows through first, and a rolled-back row gives its place back.
414
+ `refuse_commit("orders")` lets every statement through and fails the commit of a
415
+ transaction that wrote the table, for a case about what gets out before the commit.
416
+ A refusal set before the boot waits for the service to start, as a fault does.
417
+ `heal()` is the database coming back in the middle of a case: it ends `fail_with`
418
+ and every refusal and keeps the rows. The reset before every case lifts the
419
+ refusals, before it writes the rows back. They live in a schema of their own,
420
+ `specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
421
+ see.
422
+
399
423
  ## Redis
400
424
 
401
425
  ```python
@@ -213,10 +213,12 @@ fixtures, autouse ones included, would change the service the cases see.
213
213
 
214
214
  | | |
215
215
  |---|---|
216
- | `harness.<backend>.reply(method, path, status, body)` | what the neighbour answers |
217
- | `harness.<backend>.reply_to(method, path, query, status, body)` | the same, for a request whose query has these values; the reply naming the most of them wins |
218
- | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once |
219
- | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body |
216
+ | `harness.<backend>.reply(method, path, status, body, headers)` | what the neighbour answers; `headers`, strings to strings, are added to the response the client sees. A segment in braces, `/orders/{id}`, takes any one segment, the exact path wins over a template and the first declared of two templates over the second — in every call here that names a path |
217
+ | `harness.<backend>.reply_to(method, path, query, status, body, headers)` | the same, for a request whose query has these values; the reply naming the most of them wins |
218
+ | `harness.<backend>.reply_with(method, path, answer, headers)` | the answer worked out from each request: `answer` takes what `requests` returns one of and gives `(status, body)`; a reply naming no query, checked as the others |
219
+ | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once; no reply lifts it |
220
+ | `harness.<backend>.heal()` | the neighbour answers again: every `time_out` is lifted, the replies stay |
221
+ | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body; a template returns the requests it takes |
220
222
  | `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
221
223
  | `harness.run(job, *args, **kwargs)` | a job — see below |
222
224
  | `harness.command(name, *args)` | a command of the service's console; returns its exit code, and `harness.output` what it wrote |
@@ -225,6 +227,10 @@ fixtures, autouse ones included, would change the service the cases see.
225
227
  | `harness.logs` | what the service logged, under its real logging configuration |
226
228
  | `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
227
229
  | `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
230
+ | `harness.<postgres>.refuse(table, *writes)` | every insert, update and delete on the table, or those given, fails in Postgres itself, whoever sends it; reads go on working — see Postgres |
231
+ | `harness.<postgres>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
232
+ | `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
233
+ | `harness.<postgres>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
228
234
  | `harness.application` | FastAPI: the app the case runs |
229
235
 
230
236
  The service boots at the first step that reaches it, so a `Given` declares and
@@ -241,7 +247,8 @@ every request and every answer is checked against the neighbour's contract: an
241
247
  unknown route or method, an undocumented status, or a body of the wrong shape
242
248
  fails it. So does a request the service sends past the stand-ins — through
243
249
  `urllib`, or `aiohttp` reading the proxy from the environment —, which the
244
- harness's proxy refused.
250
+ harness's proxy refused. A path template in a step changes nothing there: the
251
+ check and the records start from the path the request came with.
245
252
 
246
253
  A step's request returns when the service has answered it and done everything
247
254
  the request made it do, background tasks included: nothing is in flight between
@@ -368,6 +375,23 @@ with it — through SQLAlchemy's events, or Django's execute wrappers. A fault a
368
375
  step sets before the boot waits for the service to start, so the migration and
369
376
  the start go through; the next case starts whole.
370
377
 
378
+ `harness.database.refuse("orders")` makes Postgres refuse one table's writes —
379
+ every insert, update and delete, or only those named, `Write.INSERT`,
380
+ `Write.UPDATE`, `Write.DELETE` — with `specrig: insert on orders is refused`. A
381
+ trigger refuses, not the service's driver, so every connection meets it, the
382
+ case's own included, and a write in `RETURNING` or a CTE as well as a plain one;
383
+ reads and the other tables go on working, so a case can check what the failed
384
+ write left behind. The table is `orders` or `schema.orders`. `refuse_after("orders",
385
+ 1)` lets that many rows through first, and a rolled-back row gives its place back.
386
+ `refuse_commit("orders")` lets every statement through and fails the commit of a
387
+ transaction that wrote the table, for a case about what gets out before the commit.
388
+ A refusal set before the boot waits for the service to start, as a fault does.
389
+ `heal()` is the database coming back in the middle of a case: it ends `fail_with`
390
+ and every refusal and keeps the rows. The reset before every case lifts the
391
+ refusals, before it writes the rows back. They live in a schema of their own,
392
+ `specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
393
+ see.
394
+
371
395
  ## Redis
372
396
 
373
397
  ```python
@@ -82,7 +82,7 @@ flight between steps, and `time_out` stands in for a slow neighbour, as in PHP.
82
82
  | clock | `time-machine`, from the step that sets it | the same |
83
83
  | logs | a handler on the root logger, beside the service's | the same |
84
84
  | grants | `replace(name, instance)`: the name replaced in every module of the service that holds it, and in FastAPI's `dependency_overrides` | the name replaced in every module that holds it |
85
- | Postgres | `alembic upgrade head` once per run, in a process of its own, and the rows it wrote kept aside; before every case every table but `alembic_version` and `keep` truncated and those rows written back, with the sequences where they stood; a refusal by the gate, an error by SQLAlchemy's `before_cursor_execute` | `migrate` once per run, and the rows it wrote kept aside; every table but Django's bookkeeping and `keep` truncated and those rows written back; an error by the connections' execute wrappers |
85
+ | Postgres | `alembic upgrade head` once per run, in a process of its own, and the rows it wrote kept aside; before every case every table but `alembic_version` and `keep` truncated and those rows written back, with the sequences where they stood; a refusal by the gate, an error by SQLAlchemy's `before_cursor_execute`, a table's writes by triggers in a schema of their own | `migrate` once per run, and the rows it wrote kept aside; every table but Django's bookkeeping and `keep` truncated and those rows written back; an error by the connections' execute wrappers; a table's writes by the same triggers |
86
86
  | Redis | `FLUSHDB` before every case; a refusal by the gate | the same |
87
87
  | steps | pytest-bdd: a plugin of `pytest11` marks Given, When and Then, fails a case on what the harness found, and writes `tests/flows.yaml` after a whole run | |
88
88
  | `tests/surface.yaml` | Python's `ast` over the service's modules: routes from FastAPI's decorators and routers | routes from the `ROOT_URLCONF` and the views' methods |
@@ -124,12 +124,30 @@ at import, in a lifespan, inside a job — so the stand-ins sit where every clie
124
124
  sends: `httpx`'s `HTTPTransport` and `AsyncHTTPTransport`, the same in `httpx2`,
125
125
  and `requests`' `HTTPAdapter`. Every other client meets the proxy.
126
126
 
127
+ **A template names what a step answers, not what the service sent.** As in
128
+ specrig-js, a path segment in braces takes any one segment, the exact path wins,
129
+ and of two templates the first declared; `reply`, `reply_to`, `reply_with`,
130
+ `time_out` and `requests` all take one. The contract check and the trace go on naming a request
131
+ by the path it came with, so a template that matches too much is not hidden by
132
+ the contract it is checked against. A reply's headers are added to the response
133
+ as the transports hand it to the client, so `httpx`, `httpx2` and `requests` see
134
+ them alike.
135
+
127
136
  **A gate, not an address.** PHP points a refused connection at a port where
128
137
  nothing listens, which works because the application is built anew for every
129
138
  case. Here the engine was built at import, with the address it was given, so the
130
139
  address is a gate on loopback that the case opens and closes: a refusal reaches
131
140
  every connection the service holds and every one it makes, whatever its driver.
132
141
 
142
+ **Triggers, not hooks, for a refused table.** Ported from gorib's `sqltest`: a
143
+ trigger refuses in Postgres itself, so every connection meets it — the service's,
144
+ a job's, the case's own — and the service meets the driver's own exception, where
145
+ a hook in SQLAlchemy or Django reaches only the clients that go through it. The
146
+ triggers, and the quota that lets `refuse_after`'s rows through, live in a
147
+ `specrig_refusal` schema, outside the tables the reset truncates and the baseline
148
+ copies, and the reset drops it first. A refusal a step sets before the boot waits
149
+ for the start, since the reset would lift it and the schema may not exist yet.
150
+
133
151
  **Alembic in a process of its own.** Alembic's `env.py` configures logging as it
134
152
  runs, disabling the loggers the service already made; production runs the
135
153
  migration as a job of its own, and so does the harness.
@@ -168,5 +186,5 @@ libraries.
168
186
  | 0 | the records and the fixtures | built |
169
187
  | 1 | boot, settings, HTTP stand-ins and the contract check, the pytest-bdd binding, Postgres, logs, the clock; HTTP, jobs and commands as ways in; `tests/flows.yaml` | built |
170
188
  | 2 | `tests/surface.yaml` and `tests/checks.yaml`, `init`, `rules`, `specrig.md` | built |
171
- | 3 | Redis, grants, `fail_with` | built |
189
+ | 3 | Redis, grants, `fail_with`, a refused table and `heal`, reply headers and path templates | built |
172
190
  | 4 | a bus and a queue | not started |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "specrig"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "A harness for a FastAPI or Django service developed specification first"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -44,6 +44,7 @@ Protocol = "https://github.com/specrig/protocol"
44
44
  dev = [
45
45
  "alembic>=1.16",
46
46
  "httpx>=0.27",
47
+ "httpx2>=2.0",
47
48
  "psycopg[binary]>=3.2",
48
49
  "pydantic-settings>=2.8",
49
50
  "redis>=5.0",
@@ -1,7 +1,7 @@
1
1
  # specrig.md
2
2
 
3
3
  How an agent works in this service under specrig: Rule #11, and how the suite runs.
4
- `../../.venv/bin/specrig agents` writes this file with `specrig` 0.2.0, and an edit here is lost on the next
4
+ `../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.1, and an edit here is lost on the next
5
5
  run; `AGENTS.md` beside it is the service's own.
6
6
 
7
7
  **Read `CONTRIBUTING.md` first, if it exists.** It holds service-specific
@@ -395,17 +395,29 @@ sessions; a `Then` reads back through them, not out of the answer.
395
395
  `tests/steps/` takes it as the `harness` fixture, with an attribute for each
396
396
  neighbour `harness.py` names:
397
397
 
398
- - `harness.<neighbour>.reply(method, path, status, body)` — what the neighbour
399
- answers;
398
+ - `harness.<neighbour>.reply(method, path, status, body, headers)` — what the
399
+ neighbour answers; a segment in braces, `/orders/{id}`, takes any value here and in
400
+ every call below, and the exact path wins;
400
401
  - `harness.<neighbour>.reply_to(method, path, query, status, body)` — the same, for a
401
402
  request whose query has these values;
402
- - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
403
+ - `harness.<neighbour>.reply_with(method, path, answer)` — the answer worked out from
404
+ each request, `(status, body)`;
405
+ - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time,
406
+ until `harness.<neighbour>.heal()`;
403
407
  - `harness.<neighbour>.requests(method, path)` — what reached the neighbour, for a
404
408
  `Then`.
405
409
 
406
410
  A call to a neighbour no step answered fails the case, and so does a request to an
407
411
  origin `harness.py` does not name — it is refused before it leaves.
408
412
 
413
+ **An outage is not a replacement.** `harness.<resource>.fail_with()` takes the
414
+ database or the cache away until the next case. A write that fails halfway is not an
415
+ outage: `harness.<database>.refuse(table)` makes one table refuse writes,
416
+ `refuse_after(table, rows)` lets that many rows through first, and reads keep working,
417
+ so a `Then` can check what the failed write left; `refuse_commit(table)` fails only
418
+ the commit. `heal()` is the database coming back: it ends the fault and every refusal
419
+ and keeps the rows.
420
+
409
421
  **What may be stood in for is given, not chosen.** `tests/internal/` is read-only. A
410
422
  case that needs another neighbour, or an answer the stand-in cannot give, is a stop:
411
423
  name the neighbour or the behaviour, and the assertion it would carry.
@@ -1,7 +1,7 @@
1
1
  # specrig.md
2
2
 
3
3
  How an agent works in this service under specrig: Rule #11, and how the suite runs.
4
- `../../.venv/bin/specrig agents` writes this file with `specrig` 0.2.0, and an edit here is lost on the next
4
+ `../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.1, and an edit here is lost on the next
5
5
  run; `AGENTS.md` beside it is the service's own.
6
6
 
7
7
  **Read `CONTRIBUTING.md` first, if it exists.** It holds service-specific
@@ -395,17 +395,29 @@ sessions; a `Then` reads back through them, not out of the answer.
395
395
  `tests/steps/` takes it as the `harness` fixture, with an attribute for each
396
396
  neighbour `harness.py` names:
397
397
 
398
- - `harness.<neighbour>.reply(method, path, status, body)` — what the neighbour
399
- answers;
398
+ - `harness.<neighbour>.reply(method, path, status, body, headers)` — what the
399
+ neighbour answers; a segment in braces, `/orders/{id}`, takes any value here and in
400
+ every call below, and the exact path wins;
400
401
  - `harness.<neighbour>.reply_to(method, path, query, status, body)` — the same, for a
401
402
  request whose query has these values;
402
- - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
403
+ - `harness.<neighbour>.reply_with(method, path, answer)` — the answer worked out from
404
+ each request, `(status, body)`;
405
+ - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time,
406
+ until `harness.<neighbour>.heal()`;
403
407
  - `harness.<neighbour>.requests(method, path)` — what reached the neighbour, for a
404
408
  `Then`.
405
409
 
406
410
  A call to a neighbour no step answered fails the case, and so does a request to an
407
411
  origin `harness.py` does not name — it is refused before it leaves.
408
412
 
413
+ **An outage is not a replacement.** `harness.<resource>.fail_with()` takes the
414
+ database or the cache away until the next case. A write that fails halfway is not an
415
+ outage: `harness.<database>.refuse(table)` makes one table refuse writes,
416
+ `refuse_after(table, rows)` lets that many rows through first, and reads keep working,
417
+ so a `Then` can check what the failed write left; `refuse_commit(table)` fails only
418
+ the commit. `heal()` is the database coming back: it ends the fault and every refusal
419
+ and keeps the rows.
420
+
409
421
  **What may be stood in for is given, not chosen.** `tests/internal/` is read-only. A
410
422
  case that needs another neighbour, or an answer the stand-in cannot give, is a stop:
411
423
  name the neighbour or the behaviour, and the assertion it would carry.
@@ -1,7 +1,7 @@
1
1
  from .backend import Backend, Received
2
2
  from .http import Response
3
- from .postgres import Postgres
3
+ from .postgres import Postgres, Write
4
4
  from .redis import Redis
5
5
  from .rig import Logged
6
6
 
7
- __all__ = ["Backend", "Logged", "Postgres", "Received", "Redis", "Response"]
7
+ __all__ = ["Backend", "Logged", "Postgres", "Received", "Redis", "Response", "Write"]
@@ -16,6 +16,8 @@ T = TypeVar("T")
16
16
 
17
17
  Query = Mapping[str, str | int | Sequence[str | int]]
18
18
 
19
+ Headers = Mapping[str, str]
20
+
19
21
 
20
22
  @dataclass(frozen=True)
21
23
  class Received:
@@ -25,6 +27,9 @@ class Received:
25
27
  json: Any
26
28
 
27
29
 
30
+ Answer = Callable[[Received], tuple[int, Any]]
31
+
32
+
28
33
  class StandIn(ABC):
29
34
  origin: str
30
35
  setting: str | None
@@ -53,7 +58,7 @@ class Backend(StandIn):
53
58
  self.name = ""
54
59
  self._trace = Trace()
55
60
  self._checked: Contract | None = None
56
- self._replies: dict[str, list[tuple[dict[str, list[str]], int, Any]]] = {}
61
+ self._replies: dict[str, list[tuple[dict[str, list[str]], Answer, dict[str, str]]]] = {}
57
62
  self._timeouts: dict[str, list[dict[str, list[str]]]] = {}
58
63
  self._received: list[Received] = []
59
64
  self._unanswered: list[str] = []
@@ -78,22 +83,35 @@ class Backend(StandIn):
78
83
  )
79
84
  return failures
80
85
 
81
- def reply(self, method: str, path: str, status: int, body: Any = None) -> None:
82
- self.reply_to(method, path, {}, status, body)
86
+ def reply(self, method: str, path: str, status: int, body: Any = None, headers: Headers | None = None) -> None:
87
+ self.reply_to(method, path, {}, status, body, headers)
88
+
89
+ def reply_to(
90
+ self, method: str, path: str, query: Query, status: int, body: Any = None, headers: Headers | None = None
91
+ ) -> None:
92
+ declared = copy.deepcopy(body)
93
+ self._declare(method, path, _listed(query), lambda _: (status, declared), headers)
94
+
95
+ def reply_with(self, method: str, path: str, answer: Answer, headers: Headers | None = None) -> None:
96
+ self._declare(method, path, {}, answer, headers)
83
97
 
84
- def reply_to(self, method: str, path: str, query: Query, status: int, body: Any = None) -> None:
98
+ def _declare(
99
+ self, method: str, path: str, asked: dict[str, list[str]], answer: Answer, headers: Headers | None
100
+ ) -> None:
85
101
  key = f"{method.upper()} {path}"
86
- asked = _listed(query)
87
102
  kept = [r for r in self._replies.get(key, []) if r[0] != asked]
88
- self._replies[key] = [*kept, (asked, status, copy.deepcopy(body))]
103
+ self._replies[key] = [*kept, (asked, answer, dict(headers or {}))]
89
104
 
90
105
  def time_out(self, method: str, path: str, query: Query | None = None) -> None:
91
106
  key = f"{method.upper()} {path}"
92
107
  asked = _listed(query or {})
93
108
  self._timeouts[key] = [*(q for q in self._timeouts.get(key, []) if q != asked), asked]
94
109
 
110
+ def heal(self) -> None:
111
+ self._timeouts = {}
112
+
95
113
  def requests(self, method: str, path: str) -> list[Received]:
96
- return [r for r in self._received if r.method == method.upper() and r.path == path]
114
+ return [r for r in self._received if r.method == method.upper() and _takes(path, r.path)]
97
115
 
98
116
  def answer(self, request: Request) -> Response:
99
117
  method = request.method.upper()
@@ -103,31 +121,54 @@ class Backend(StandIn):
103
121
  body = http.decoded(request.body)
104
122
  call = self._trace.begin()
105
123
  try:
106
- self._received.append(Received(method, path, asked, body))
124
+ received = Received(method, path, asked, body)
125
+ self._received.append(received)
107
126
  if self._checked is not None:
108
127
  self._violations.extend(self._checked.request(method, path, body))
109
- if _pick(self._timeouts.get(key, []), asked, lambda q: q) is not None:
128
+ if _pick(_declared(self._timeouts, method, path), asked, lambda q: q) is not None:
110
129
  raise Unreachable(f"{self.name} did not answer {key} in time", timed_out=True)
111
- return self._fulfil(method, path, key + request.search(), asked)
130
+ return self._fulfil(received, key + request.search())
112
131
  finally:
113
132
  name = self._checked.name(method, path) if self._checked is not None else key
114
133
  self._trace.end(call, "out", "http", name, body)
115
134
 
116
- def _fulfil(self, method: str, path: str, asked: str, query: dict[str, list[str]]) -> Response:
117
- reply = _pick(self._replies.get(f"{method} {path}", []), query, lambda r: r[0])
135
+ def _fulfil(self, received: Received, asked: str) -> Response:
136
+ method, path = received.method, received.path
137
+ reply = _pick(_declared(self._replies, method, path), received.query, lambda r: r[0])
118
138
  if reply is None:
119
139
  self._unanswered.append(asked)
120
140
  return _respond(501, {"code": "unanswered", "message": f"{method} {path}"})
121
- _, status, body = reply
141
+ _, answer, headers = reply
142
+ status, body = answer(received)
122
143
  if self._checked is not None:
123
144
  self._violations.extend(self._checked.response(method, path, status, {} if body is None else body))
124
- return _respond(status, None if status == 204 else ({} if body is None else body))
145
+ return _respond(status, None if status == 204 else ({} if body is None else body), headers)
125
146
 
126
147
 
127
- def _respond(status: int, body: Any) -> Response:
148
+ def _respond(status: int, body: Any, headers: Headers | None = None) -> Response:
149
+ given = dict(headers or {})
128
150
  if body is None:
129
- return Response(status)
130
- return Response(status, {"Content-Type": "application/json"}, http.encoded(body))
151
+ return Response(status, given)
152
+ typed = {} if "content-type" in {name.lower() for name in given} else {"Content-Type": "application/json"}
153
+ return Response(status, {**typed, **given}, http.encoded(body))
154
+
155
+
156
+ def _declared(table: dict[str, list[T]], method: str, path: str) -> list[T]:
157
+ exact = table.get(f"{method} {path}")
158
+ if exact is not None:
159
+ return exact
160
+ prefix = f"{method} "
161
+ for key, found in table.items():
162
+ if key.startswith(prefix) and _takes(key[len(prefix) :], path):
163
+ return found
164
+ return []
165
+
166
+
167
+ def _takes(template: str, path: str) -> bool:
168
+ segments, wanted = template.split("/"), path.split("/")
169
+ return len(segments) == len(wanted) and all(
170
+ (s.startswith("{") and s.endswith("}")) or s == w for s, w in zip(segments, wanted, strict=True)
171
+ )
131
172
 
132
173
 
133
174
  def _pick(candidates: list[T], query: dict[str, list[str]], of: Callable[[T], dict[str, list[str]]]) -> T | None:
@@ -6,6 +6,7 @@ import threading
6
6
  import time
7
7
  from collections.abc import Callable, Sequence
8
8
  from dataclasses import dataclass, replace
9
+ from enum import StrEnum
9
10
  from typing import TYPE_CHECKING, Any
10
11
  from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
11
12
 
@@ -19,6 +20,38 @@ if TYPE_CHECKING:
19
20
 
20
21
  _MARK = "specrig "
21
22
  _BASELINE = "specrig_baseline"
23
+ _REFUSAL = "specrig_refusal"
24
+
25
+ _REFUSAL_SETUP = f"""
26
+ CREATE SCHEMA IF NOT EXISTS {_REFUSAL};
27
+ CREATE TABLE IF NOT EXISTS {_REFUSAL}.quota (
28
+ relation oid,
29
+ name text,
30
+ remaining int NOT NULL,
31
+ PRIMARY KEY (relation, name)
32
+ );
33
+ CREATE OR REPLACE FUNCTION {_REFUSAL}.refuse() RETURNS trigger LANGUAGE plpgsql AS $$
34
+ DECLARE
35
+ rest int;
36
+ BEGIN
37
+ UPDATE {_REFUSAL}.quota SET remaining = remaining - 1
38
+ WHERE relation = tg_relid AND name = tg_name
39
+ RETURNING remaining INTO rest;
40
+ IF rest < 0 THEN
41
+ RAISE EXCEPTION 'specrig: % on % is refused', lower(tg_op), tg_table_name;
42
+ END IF;
43
+ IF tg_op = 'DELETE' THEN
44
+ RETURN old;
45
+ END IF;
46
+ RETURN new;
47
+ END $$;
48
+ """
49
+
50
+
51
+ class Write(StrEnum):
52
+ INSERT = "insert"
53
+ UPDATE = "update"
54
+ DELETE = "delete"
22
55
 
23
56
 
24
57
  @dataclass(frozen=True)
@@ -176,7 +209,34 @@ class _Server:
176
209
  stood = connection.execute(f"SELECT last_value, is_called FROM {sequence}").fetchone()
177
210
  self._sequences.append((sequence, *stood))
178
211
 
212
+ def refuse(self, table: str, rows: int, events: Sequence[str], commit: bool) -> None:
213
+ relation = ".".join(_quoted(part) for part in table.split("."))
214
+ name = "specrig_refuse_" + ("commit_" if commit else "") + "_".join(events)
215
+ on = " OR ".join(events)
216
+ trigger = (
217
+ f"CREATE CONSTRAINT TRIGGER {name} AFTER {on} ON {relation} DEFERRABLE INITIALLY DEFERRED FOR EACH ROW"
218
+ if commit
219
+ else f"CREATE TRIGGER {name} BEFORE {on} ON {relation} FOR EACH ROW"
220
+ )
221
+ connection = self._connected()
222
+ try:
223
+ with connection.transaction():
224
+ connection.execute(_REFUSAL_SETUP)
225
+ connection.execute(
226
+ f"INSERT INTO {_REFUSAL}.quota (relation, name, remaining) VALUES (%s::regclass, %s, %s)"
227
+ " ON CONFLICT (relation, name) DO UPDATE SET remaining = excluded.remaining",
228
+ (relation, name, rows),
229
+ )
230
+ connection.execute(f"DROP TRIGGER IF EXISTS {name} ON {relation}")
231
+ connection.execute(f"{trigger} EXECUTE FUNCTION {_REFUSAL}.refuse()")
232
+ except Exception as e:
233
+ raise RuntimeError(f"cannot refuse writes to {table}: {e}") from e
234
+
235
+ def lift(self) -> None:
236
+ self._connected().execute(f"DROP SCHEMA IF EXISTS {_REFUSAL} CASCADE")
237
+
179
238
  def reset(self, keep: Sequence[str]) -> None:
239
+ self.lift()
180
240
  tables = [f"{_quoted(schema)}.{_quoted(table)}" for schema, table in self._tables(keep)]
181
241
  if not tables:
182
242
  return
@@ -196,8 +256,8 @@ class _Server:
196
256
  def _tables(self, keep: Sequence[str]) -> list[tuple[str, str]]:
197
257
  found = self._connected().execute(
198
258
  "SELECT schemaname, tablename FROM pg_tables"
199
- " WHERE schemaname NOT IN ('pg_catalog', 'information_schema', %s) ORDER BY 1, 2",
200
- (_BASELINE,),
259
+ " WHERE schemaname NOT IN ('pg_catalog', 'information_schema', %s, %s) ORDER BY 1, 2",
260
+ (_BASELINE, _REFUSAL),
201
261
  )
202
262
  return [(schema, table) for schema, table in found.fetchall() if table not in keep]
203
263
 
@@ -236,6 +296,7 @@ class Postgres(Resource):
236
296
  self._started = False
237
297
  self._refusing = False
238
298
  self._error: BaseException | None = None
299
+ self._refusals: list[Callable[[], None]] = []
239
300
 
240
301
  def fresh(self) -> Postgres:
241
302
  return Postgres(self.setting, self._given, self._image, self._keep, self._scheme)
@@ -269,15 +330,45 @@ class Postgres(Resource):
269
330
  if self._started:
270
331
  self._apply()
271
332
 
333
+ def refuse(self, table: str, *writes: Write) -> None:
334
+ self.refuse_after(table, 0, *writes)
335
+
336
+ def refuse_after(self, table: str, rows: int, *writes: Write) -> None:
337
+ self._refuse(table, rows, writes, False)
338
+
339
+ def refuse_commit(self, table: str, *writes: Write) -> None:
340
+ self._refuse(table, 0, writes, True)
341
+
342
+ def heal(self) -> None:
343
+ self._calm()
344
+ if self._started:
345
+ self._held().lift()
346
+
272
347
  def release(self) -> None:
348
+ self._calm()
349
+ self._rig = None
350
+ self._started = False
351
+
352
+ def _calm(self) -> None:
273
353
  if self._server is not None:
274
354
  self._server.gate.reopen()
275
355
  if self._rig is not None and (self._refusing or self._error is not None):
276
356
  self._rig.databases().heal()
277
- self._rig = None
278
- self._started = False
279
357
  self._refusing = False
280
358
  self._error = None
359
+ self._refusals = []
360
+
361
+ def _refuse(self, table: str, rows: int, writes: Sequence[Write], commit: bool) -> None:
362
+ events = _events(writes)
363
+
364
+ def refusal() -> None:
365
+ self._held().refuse(table, rows, events, commit)
366
+
367
+ # Before the start the schema may not exist, and the reset would lift it.
368
+ if self._started:
369
+ refusal()
370
+ else:
371
+ self._refusals.append(refusal)
281
372
 
282
373
  def _apply(self) -> None:
283
374
  assert self._rig is not None
@@ -285,6 +376,9 @@ class Postgres(Resource):
285
376
  self._held().gate.refuse()
286
377
  if self._error is not None:
287
378
  self._rig.databases().fail(self._held().database, self._error)
379
+ refusals, self._refusals = self._refusals, []
380
+ for refusal in refusals:
381
+ refusal()
288
382
 
289
383
  def _of(self, service: str) -> _Server:
290
384
  if self._server is None:
@@ -327,5 +421,15 @@ def _sweep(connection: Any, stem: str) -> None:
327
421
  connection.execute(f"DROP DATABASE IF EXISTS {_quoted(name)} WITH (FORCE)")
328
422
 
329
423
 
424
+ def _events(writes: Sequence[str]) -> list[str]:
425
+ given = []
426
+ for write in writes:
427
+ try:
428
+ given.append(Write(write))
429
+ except ValueError:
430
+ raise ValueError(f"specrig: {write!r} is not a write, refuse an insert, update or delete") from None
431
+ return [write.value for write in Write if not given or write in given]
432
+
433
+
330
434
  def _quoted(name: str) -> str:
331
435
  return '"' + name.replace('"', '""') + '"'
@@ -161,17 +161,29 @@ sessions; a `Then` reads back through them, not out of the answer.
161
161
  `tests/steps/` takes it as the `harness` fixture, with an attribute for each
162
162
  neighbour `harness.py` names:
163
163
 
164
- - `harness.<neighbour>.reply(method, path, status, body)` — what the neighbour
165
- answers;
164
+ - `harness.<neighbour>.reply(method, path, status, body, headers)` — what the
165
+ neighbour answers; a segment in braces, `/orders/{id}`, takes any value here and in
166
+ every call below, and the exact path wins;
166
167
  - `harness.<neighbour>.reply_to(method, path, query, status, body)` — the same, for a
167
168
  request whose query has these values;
168
- - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
169
+ - `harness.<neighbour>.reply_with(method, path, answer)` — the answer worked out from
170
+ each request, `(status, body)`;
171
+ - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time,
172
+ until `harness.<neighbour>.heal()`;
169
173
  - `harness.<neighbour>.requests(method, path)` — what reached the neighbour, for a
170
174
  `Then`.
171
175
 
172
176
  A call to a neighbour no step answered fails the case, and so does a request to an
173
177
  origin `harness.py` does not name — it is refused before it leaves.
174
178
 
179
+ **An outage is not a replacement.** `harness.<resource>.fail_with()` takes the
180
+ database or the cache away until the next case. A write that fails halfway is not an
181
+ outage: `harness.<database>.refuse(table)` makes one table refuse writes,
182
+ `refuse_after(table, rows)` lets that many rows through first, and reads keep working,
183
+ so a `Then` can check what the failed write left; `refuse_commit(table)` fails only
184
+ the commit. `heal()` is the database coming back: it ends the fault and every refusal
185
+ and keeps the rows.
186
+
175
187
  **What may be stood in for is given, not chosen.** `tests/internal/` is read-only. A
176
188
  case that needs another neighbour, or an answer the stand-in cannot give, is a stop:
177
189
  name the neighbour or the behaviour, and the assertion it would carry.