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.
- {specrig-0.2.0 → specrig-0.3.1}/PKG-INFO +30 -6
- {specrig-0.2.0 → specrig-0.3.1}/README.md +29 -5
- {specrig-0.2.0 → specrig-0.3.1}/docs/design.md +20 -2
- {specrig-0.2.0 → specrig-0.3.1}/pyproject.toml +2 -1
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/specrig.md +16 -4
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/specrig.md +16 -4
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/__init__.py +2 -2
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/backend.py +58 -17
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/postgres.py +108 -4
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/templates.py +15 -3
- specrig-0.3.1/tests/test_backend.py +295 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/test_harness.py +131 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/test_postgres.py +206 -1
- specrig-0.2.0/tests/test_backend.py +0 -109
- {specrig-0.2.0 → specrig-0.3.1}/.github/workflows/release.yml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/.gitignore +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/LICENSE +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/Makefile +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/docs/billing.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/AGENTS.md +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/CLAUDE.md +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/config/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/config/settings.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/config/urls.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/config/wsgi.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/manage.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/pyproject.toml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/billing.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/jobs.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/management/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/management/commands/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/management/commands/remind.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/migrations/0001_initial.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/migrations/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/models.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/reminders/views.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/specrig.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/checks.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/features/history.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/features/reminders.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/flows.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/internal/boot.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/internal/harness.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/internal/test_cases.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/steps/history.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/steps/reminders.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/reminders/tests/surface.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/AGENTS.md +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/CLAUDE.md +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/alembic.ini +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/migrations/env.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/migrations/versions/0001_orders.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/pyproject.toml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/billing.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/cli.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/db.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/jobs.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/main.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/shop/settings.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/specrig.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/checks.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/features/orders.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/features/placing.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/flows.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/internal/boot.feature +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/internal/harness.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/internal/test_cases.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/steps/orders.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/steps/placing.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/samples/shop/tests/surface.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/__main__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/_yaml.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/asgi.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/clock.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/calls.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/django.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/fastapi.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/index.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/code/settings.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/contract.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/django/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/docker.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/fastapi/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/flows.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/gate.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/http.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/contributing.md +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/header.md +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/reference.md +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/rule11.md +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/agents/short.md +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/protocol/modes.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/proxy.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/py.typed +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/records.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/redis.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/resource.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/rig.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/run.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/standins.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/suite.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/surface.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/agents.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/cli.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/init.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/modes.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/src/specrig/tool/service.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/manage.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/pyproject.toml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/api.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/settings.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/urls.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/views.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/django/stock/warehouse.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/.env.example +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/config.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/crm.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/main.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/routes/__init__.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/routes/orders.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/sessions.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/fastapi/pyproject.toml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/agents/verdicts.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/markers/areas.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/records/reminders.flows.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/records/reminders.trace.json +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/records/shop.flows.yaml +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/fixtures/protocol/records/shop.trace.json +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/test_agents.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/test_flows.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/test_format.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/test_gate.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/test_init.py +0 -0
- {specrig-0.2.0 → specrig-0.3.1}/tests/test_rules.py +0 -0
- {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.
|
|
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>.
|
|
247
|
-
| `harness.<backend>.
|
|
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>.
|
|
219
|
-
| `harness.<backend>.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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>.
|
|
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.
|
|
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
|
|
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>.
|
|
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]],
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
|
117
|
-
|
|
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
|
-
_,
|
|
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
|
-
|
|
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
|
|
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>.
|
|
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.
|