specrig 0.2.0__tar.gz → 0.3.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.
- {specrig-0.2.0 → specrig-0.3.0}/PKG-INFO +27 -5
- {specrig-0.2.0 → specrig-0.3.0}/README.md +26 -4
- {specrig-0.2.0 → specrig-0.3.0}/docs/design.md +20 -2
- {specrig-0.2.0 → specrig-0.3.0}/pyproject.toml +2 -1
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/specrig.md +12 -3
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/specrig.md +12 -3
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/__init__.py +2 -2
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/backend.py +37 -13
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/postgres.py +108 -4
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/templates.py +11 -2
- specrig-0.3.0/tests/test_backend.py +226 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/test_harness.py +131 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/test_postgres.py +206 -1
- specrig-0.2.0/tests/test_backend.py +0 -109
- {specrig-0.2.0 → specrig-0.3.0}/.github/workflows/release.yml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/.gitignore +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/LICENSE +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/Makefile +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/docs/billing.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/AGENTS.md +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/CLAUDE.md +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/config/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/config/settings.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/config/urls.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/config/wsgi.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/manage.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/pyproject.toml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/billing.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/jobs.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/management/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/management/commands/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/management/commands/remind.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/migrations/0001_initial.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/migrations/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/models.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/reminders/views.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/specrig.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/checks.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/features/history.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/features/reminders.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/flows.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/internal/boot.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/internal/harness.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/internal/test_cases.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/steps/history.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/steps/reminders.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/reminders/tests/surface.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/AGENTS.md +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/CLAUDE.md +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/alembic.ini +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/migrations/env.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/migrations/versions/0001_orders.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/pyproject.toml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/billing.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/cli.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/db.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/jobs.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/main.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/shop/settings.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/specrig.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/checks.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/features/orders.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/features/placing.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/flows.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/internal/boot.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/internal/harness.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/internal/test_cases.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/steps/orders.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/steps/placing.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/samples/shop/tests/surface.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/__main__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/_yaml.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/asgi.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/clock.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/calls.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/django.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/fastapi.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/index.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/code/settings.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/contract.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/django/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/docker.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/fastapi/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/flows.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/gate.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/http.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/contributing.md +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/header.md +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/reference.md +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/rule11.md +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/agents/short.md +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/protocol/modes.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/proxy.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/py.typed +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/records.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/redis.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/resource.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/rig.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/run.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/standins.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/suite.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/surface.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/agents.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/cli.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/init.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/modes.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/src/specrig/tool/service.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/manage.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/pyproject.toml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/api.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/settings.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/urls.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/views.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/django/stock/warehouse.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/.env.example +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/config.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/crm.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/main.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/routes/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/routes/orders.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/gateway/sessions.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/fastapi/pyproject.toml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/agents/verdicts.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/markers/areas.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/records/reminders.flows.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/records/reminders.trace.json +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/records/shop.flows.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/fixtures/protocol/records/shop.trace.json +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/test_agents.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/test_flows.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/test_format.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/test_gate.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/test_init.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/test_rules.py +0 -0
- {specrig-0.2.0 → specrig-0.3.0}/tests/test_surface.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: specrig
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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,10 @@ 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 |
|
|
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
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 |
|
|
247
|
+
| `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
248
|
| `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
|
|
249
249
|
| `harness.run(job, *args, **kwargs)` | a job — see below |
|
|
250
250
|
| `harness.command(name, *args)` | a command of the service's console; returns its exit code, and `harness.output` what it wrote |
|
|
@@ -253,6 +253,10 @@ fixtures, autouse ones included, would change the service the cases see.
|
|
|
253
253
|
| `harness.logs` | what the service logged, under its real logging configuration |
|
|
254
254
|
| `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
|
|
255
255
|
| `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
|
|
256
|
+
| `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 |
|
|
257
|
+
| `harness.<postgres>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
|
|
258
|
+
| `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
|
|
259
|
+
| `harness.<postgres>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
|
|
256
260
|
| `harness.application` | FastAPI: the app the case runs |
|
|
257
261
|
|
|
258
262
|
The service boots at the first step that reaches it, so a `Given` declares and
|
|
@@ -269,7 +273,8 @@ every request and every answer is checked against the neighbour's contract: an
|
|
|
269
273
|
unknown route or method, an undocumented status, or a body of the wrong shape
|
|
270
274
|
fails it. So does a request the service sends past the stand-ins — through
|
|
271
275
|
`urllib`, or `aiohttp` reading the proxy from the environment —, which the
|
|
272
|
-
harness's proxy refused.
|
|
276
|
+
harness's proxy refused. A path template in a step changes nothing there: the
|
|
277
|
+
check and the records start from the path the request came with.
|
|
273
278
|
|
|
274
279
|
A step's request returns when the service has answered it and done everything
|
|
275
280
|
the request made it do, background tasks included: nothing is in flight between
|
|
@@ -396,6 +401,23 @@ with it — through SQLAlchemy's events, or Django's execute wrappers. A fault a
|
|
|
396
401
|
step sets before the boot waits for the service to start, so the migration and
|
|
397
402
|
the start go through; the next case starts whole.
|
|
398
403
|
|
|
404
|
+
`harness.database.refuse("orders")` makes Postgres refuse one table's writes —
|
|
405
|
+
every insert, update and delete, or only those named, `Write.INSERT`,
|
|
406
|
+
`Write.UPDATE`, `Write.DELETE` — with `specrig: insert on orders is refused`. A
|
|
407
|
+
trigger refuses, not the service's driver, so every connection meets it, the
|
|
408
|
+
case's own included, and a write in `RETURNING` or a CTE as well as a plain one;
|
|
409
|
+
reads and the other tables go on working, so a case can check what the failed
|
|
410
|
+
write left behind. The table is `orders` or `schema.orders`. `refuse_after("orders",
|
|
411
|
+
1)` lets that many rows through first, and a rolled-back row gives its place back.
|
|
412
|
+
`refuse_commit("orders")` lets every statement through and fails the commit of a
|
|
413
|
+
transaction that wrote the table, for a case about what gets out before the commit.
|
|
414
|
+
A refusal set before the boot waits for the service to start, as a fault does.
|
|
415
|
+
`heal()` is the database coming back in the middle of a case: it ends `fail_with`
|
|
416
|
+
and every refusal and keeps the rows. The reset before every case lifts the
|
|
417
|
+
refusals, before it writes the rows back. They live in a schema of their own,
|
|
418
|
+
`specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
|
|
419
|
+
see.
|
|
420
|
+
|
|
399
421
|
## Redis
|
|
400
422
|
|
|
401
423
|
```python
|
|
@@ -213,10 +213,10 @@ 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 |
|
|
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
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 |
|
|
219
|
+
| `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
220
|
| `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
|
|
221
221
|
| `harness.run(job, *args, **kwargs)` | a job — see below |
|
|
222
222
|
| `harness.command(name, *args)` | a command of the service's console; returns its exit code, and `harness.output` what it wrote |
|
|
@@ -225,6 +225,10 @@ fixtures, autouse ones included, would change the service the cases see.
|
|
|
225
225
|
| `harness.logs` | what the service logged, under its real logging configuration |
|
|
226
226
|
| `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
|
|
227
227
|
| `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
|
|
228
|
+
| `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 |
|
|
229
|
+
| `harness.<postgres>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
|
|
230
|
+
| `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
|
|
231
|
+
| `harness.<postgres>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
|
|
228
232
|
| `harness.application` | FastAPI: the app the case runs |
|
|
229
233
|
|
|
230
234
|
The service boots at the first step that reaches it, so a `Given` declares and
|
|
@@ -241,7 +245,8 @@ every request and every answer is checked against the neighbour's contract: an
|
|
|
241
245
|
unknown route or method, an undocumented status, or a body of the wrong shape
|
|
242
246
|
fails it. So does a request the service sends past the stand-ins — through
|
|
243
247
|
`urllib`, or `aiohttp` reading the proxy from the environment —, which the
|
|
244
|
-
harness's proxy refused.
|
|
248
|
+
harness's proxy refused. A path template in a step changes nothing there: the
|
|
249
|
+
check and the records start from the path the request came with.
|
|
245
250
|
|
|
246
251
|
A step's request returns when the service has answered it and done everything
|
|
247
252
|
the request made it do, background tasks included: nothing is in flight between
|
|
@@ -368,6 +373,23 @@ with it — through SQLAlchemy's events, or Django's execute wrappers. A fault a
|
|
|
368
373
|
step sets before the boot waits for the service to start, so the migration and
|
|
369
374
|
the start go through; the next case starts whole.
|
|
370
375
|
|
|
376
|
+
`harness.database.refuse("orders")` makes Postgres refuse one table's writes —
|
|
377
|
+
every insert, update and delete, or only those named, `Write.INSERT`,
|
|
378
|
+
`Write.UPDATE`, `Write.DELETE` — with `specrig: insert on orders is refused`. A
|
|
379
|
+
trigger refuses, not the service's driver, so every connection meets it, the
|
|
380
|
+
case's own included, and a write in `RETURNING` or a CTE as well as a plain one;
|
|
381
|
+
reads and the other tables go on working, so a case can check what the failed
|
|
382
|
+
write left behind. The table is `orders` or `schema.orders`. `refuse_after("orders",
|
|
383
|
+
1)` lets that many rows through first, and a rolled-back row gives its place back.
|
|
384
|
+
`refuse_commit("orders")` lets every statement through and fails the commit of a
|
|
385
|
+
transaction that wrote the table, for a case about what gets out before the commit.
|
|
386
|
+
A refusal set before the boot waits for the service to start, as a fault does.
|
|
387
|
+
`heal()` is the database coming back in the middle of a case: it ends `fail_with`
|
|
388
|
+
and every refusal and keeps the rows. The reset before every case lifts the
|
|
389
|
+
refusals, before it writes the rows back. They live in a schema of their own,
|
|
390
|
+
`specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
|
|
391
|
+
see.
|
|
392
|
+
|
|
371
393
|
## Redis
|
|
372
394
|
|
|
373
395
|
```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
|
|
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`, `time_out` and
|
|
130
|
+
`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.
|
|
7
|
+
version = "0.3.0"
|
|
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.
|
|
4
|
+
`../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.0, 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,8 +395,9 @@ 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
|
|
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
403
|
- `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
|
|
@@ -406,6 +407,14 @@ neighbour `harness.py` names:
|
|
|
406
407
|
A call to a neighbour no step answered fails the case, and so does a request to an
|
|
407
408
|
origin `harness.py` does not name — it is refused before it leaves.
|
|
408
409
|
|
|
410
|
+
**An outage is not a replacement.** `harness.<resource>.fail_with()` takes the
|
|
411
|
+
database or the cache away until the next case. A write that fails halfway is not an
|
|
412
|
+
outage: `harness.<database>.refuse(table)` makes one table refuse writes,
|
|
413
|
+
`refuse_after(table, rows)` lets that many rows through first, and reads keep working,
|
|
414
|
+
so a `Then` can check what the failed write left; `refuse_commit(table)` fails only
|
|
415
|
+
the commit. `heal()` is the database coming back: it ends the fault and every refusal
|
|
416
|
+
and keeps the rows.
|
|
417
|
+
|
|
409
418
|
**What may be stood in for is given, not chosen.** `tests/internal/` is read-only. A
|
|
410
419
|
case that needs another neighbour, or an answer the stand-in cannot give, is a stop:
|
|
411
420
|
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.
|
|
4
|
+
`../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.0, 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,8 +395,9 @@ 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
|
|
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
403
|
- `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
|
|
@@ -406,6 +407,14 @@ neighbour `harness.py` names:
|
|
|
406
407
|
A call to a neighbour no step answered fails the case, and so does a request to an
|
|
407
408
|
origin `harness.py` does not name — it is refused before it leaves.
|
|
408
409
|
|
|
410
|
+
**An outage is not a replacement.** `harness.<resource>.fail_with()` takes the
|
|
411
|
+
database or the cache away until the next case. A write that fails halfway is not an
|
|
412
|
+
outage: `harness.<database>.refuse(table)` makes one table refuse writes,
|
|
413
|
+
`refuse_after(table, rows)` lets that many rows through first, and reads keep working,
|
|
414
|
+
so a `Then` can check what the failed write left; `refuse_commit(table)` fails only
|
|
415
|
+
the commit. `heal()` is the database coming back: it ends the fault and every refusal
|
|
416
|
+
and keeps the rows.
|
|
417
|
+
|
|
409
418
|
**What may be stood in for is given, not chosen.** `tests/internal/` is read-only. A
|
|
410
419
|
case that needs another neighbour, or an answer the stand-in cannot give, is a stop:
|
|
411
420
|
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:
|
|
@@ -53,7 +55,7 @@ class Backend(StandIn):
|
|
|
53
55
|
self.name = ""
|
|
54
56
|
self._trace = Trace()
|
|
55
57
|
self._checked: Contract | None = None
|
|
56
|
-
self._replies: dict[str, list[tuple[dict[str, list[str]], int, Any]]] = {}
|
|
58
|
+
self._replies: dict[str, list[tuple[dict[str, list[str]], int, Any, dict[str, str]]]] = {}
|
|
57
59
|
self._timeouts: dict[str, list[dict[str, list[str]]]] = {}
|
|
58
60
|
self._received: list[Received] = []
|
|
59
61
|
self._unanswered: list[str] = []
|
|
@@ -78,14 +80,16 @@ class Backend(StandIn):
|
|
|
78
80
|
)
|
|
79
81
|
return failures
|
|
80
82
|
|
|
81
|
-
def reply(self, method: str, path: str, status: int, body: Any = None) -> None:
|
|
82
|
-
self.reply_to(method, path, {}, status, body)
|
|
83
|
+
def reply(self, method: str, path: str, status: int, body: Any = None, headers: Headers | None = None) -> None:
|
|
84
|
+
self.reply_to(method, path, {}, status, body, headers)
|
|
83
85
|
|
|
84
|
-
def reply_to(
|
|
86
|
+
def reply_to(
|
|
87
|
+
self, method: str, path: str, query: Query, status: int, body: Any = None, headers: Headers | None = None
|
|
88
|
+
) -> None:
|
|
85
89
|
key = f"{method.upper()} {path}"
|
|
86
90
|
asked = _listed(query)
|
|
87
91
|
kept = [r for r in self._replies.get(key, []) if r[0] != asked]
|
|
88
|
-
self._replies[key] = [*kept, (asked, status, copy.deepcopy(body))]
|
|
92
|
+
self._replies[key] = [*kept, (asked, status, copy.deepcopy(body), dict(headers or {}))]
|
|
89
93
|
|
|
90
94
|
def time_out(self, method: str, path: str, query: Query | None = None) -> None:
|
|
91
95
|
key = f"{method.upper()} {path}"
|
|
@@ -93,7 +97,7 @@ class Backend(StandIn):
|
|
|
93
97
|
self._timeouts[key] = [*(q for q in self._timeouts.get(key, []) if q != asked), asked]
|
|
94
98
|
|
|
95
99
|
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
|
|
100
|
+
return [r for r in self._received if r.method == method.upper() and _takes(path, r.path)]
|
|
97
101
|
|
|
98
102
|
def answer(self, request: Request) -> Response:
|
|
99
103
|
method = request.method.upper()
|
|
@@ -106,7 +110,7 @@ class Backend(StandIn):
|
|
|
106
110
|
self._received.append(Received(method, path, asked, body))
|
|
107
111
|
if self._checked is not None:
|
|
108
112
|
self._violations.extend(self._checked.request(method, path, body))
|
|
109
|
-
if _pick(self._timeouts
|
|
113
|
+
if _pick(_declared(self._timeouts, method, path), asked, lambda q: q) is not None:
|
|
110
114
|
raise Unreachable(f"{self.name} did not answer {key} in time", timed_out=True)
|
|
111
115
|
return self._fulfil(method, path, key + request.search(), asked)
|
|
112
116
|
finally:
|
|
@@ -114,20 +118,40 @@ class Backend(StandIn):
|
|
|
114
118
|
self._trace.end(call, "out", "http", name, body)
|
|
115
119
|
|
|
116
120
|
def _fulfil(self, method: str, path: str, asked: str, query: dict[str, list[str]]) -> Response:
|
|
117
|
-
reply = _pick(self._replies
|
|
121
|
+
reply = _pick(_declared(self._replies, method, path), query, lambda r: r[0])
|
|
118
122
|
if reply is None:
|
|
119
123
|
self._unanswered.append(asked)
|
|
120
124
|
return _respond(501, {"code": "unanswered", "message": f"{method} {path}"})
|
|
121
|
-
_, status, body = reply
|
|
125
|
+
_, status, body, headers = reply
|
|
122
126
|
if self._checked is not None:
|
|
123
127
|
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))
|
|
128
|
+
return _respond(status, None if status == 204 else ({} if body is None else body), headers)
|
|
125
129
|
|
|
126
130
|
|
|
127
|
-
def _respond(status: int, body: Any) -> Response:
|
|
131
|
+
def _respond(status: int, body: Any, headers: Headers | None = None) -> Response:
|
|
132
|
+
given = dict(headers or {})
|
|
128
133
|
if body is None:
|
|
129
|
-
return Response(status)
|
|
130
|
-
|
|
134
|
+
return Response(status, given)
|
|
135
|
+
typed = {} if "content-type" in {name.lower() for name in given} else {"Content-Type": "application/json"}
|
|
136
|
+
return Response(status, {**typed, **given}, http.encoded(body))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _declared(table: dict[str, list[T]], method: str, path: str) -> list[T]:
|
|
140
|
+
exact = table.get(f"{method} {path}")
|
|
141
|
+
if exact is not None:
|
|
142
|
+
return exact
|
|
143
|
+
prefix = f"{method} "
|
|
144
|
+
for key, found in table.items():
|
|
145
|
+
if key.startswith(prefix) and _takes(key[len(prefix) :], path):
|
|
146
|
+
return found
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _takes(template: str, path: str) -> bool:
|
|
151
|
+
segments, wanted = template.split("/"), path.split("/")
|
|
152
|
+
return len(segments) == len(wanted) and all(
|
|
153
|
+
(s.startswith("{") and s.endswith("}")) or s == w for s, w in zip(segments, wanted, strict=True)
|
|
154
|
+
)
|
|
131
155
|
|
|
132
156
|
|
|
133
157
|
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,8 +161,9 @@ 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
|
|
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
169
|
- `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
|
|
@@ -172,6 +173,14 @@ neighbour `harness.py` names:
|
|
|
172
173
|
A call to a neighbour no step answered fails the case, and so does a request to an
|
|
173
174
|
origin `harness.py` does not name — it is refused before it leaves.
|
|
174
175
|
|
|
176
|
+
**An outage is not a replacement.** `harness.<resource>.fail_with()` takes the
|
|
177
|
+
database or the cache away until the next case. A write that fails halfway is not an
|
|
178
|
+
outage: `harness.<database>.refuse(table)` makes one table refuse writes,
|
|
179
|
+
`refuse_after(table, rows)` lets that many rows through first, and reads keep working,
|
|
180
|
+
so a `Then` can check what the failed write left; `refuse_commit(table)` fails only
|
|
181
|
+
the commit. `heal()` is the database coming back: it ends the fault and every refusal
|
|
182
|
+
and keeps the rows.
|
|
183
|
+
|
|
175
184
|
**What may be stood in for is given, not chosen.** `tests/internal/` is read-only. A
|
|
176
185
|
case that needs another neighbour, or an answer the stand-in cannot give, is a stop:
|
|
177
186
|
name the neighbour or the behaviour, and the assertion it would carry.
|