specrig 0.3.0__tar.gz → 0.3.2__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.3.0 → specrig-0.3.2}/Makefile +1 -1
- specrig-0.3.0/README.md → specrig-0.3.2/PKG-INFO +147 -20
- specrig-0.3.0/PKG-INFO → specrig-0.3.2/README.md +116 -48
- {specrig-0.3.0 → specrig-0.3.2}/docs/design.md +58 -7
- {specrig-0.3.0 → specrig-0.3.2}/pyproject.toml +10 -4
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/specrig.md +8 -5
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/specrig.md +8 -5
- specrig-0.3.2/src/specrig/__init__.py +10 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/backend.py +25 -8
- specrig-0.3.2/src/specrig/database.py +165 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/django/__init__.py +2 -1
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/docker.py +5 -1
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/fastapi/__init__.py +13 -5
- specrig-0.3.2/src/specrig/mysql.py +318 -0
- specrig-0.3.2/src/specrig/postgres.py +193 -0
- specrig-0.3.2/src/specrig/server.py +194 -0
- specrig-0.3.2/src/specrig/sqlite.py +194 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/tool/init.py +64 -11
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/tool/templates.py +7 -4
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_backend.py +69 -0
- specrig-0.3.2/tests/test_docker.py +39 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_harness.py +191 -1
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_init.py +116 -0
- specrig-0.3.2/tests/test_mysql.py +612 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_postgres.py +41 -0
- specrig-0.3.2/tests/test_sqlite.py +426 -0
- specrig-0.3.0/src/specrig/__init__.py +0 -7
- specrig-0.3.0/src/specrig/postgres.py +0 -435
- {specrig-0.3.0 → specrig-0.3.2}/.github/workflows/release.yml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/.gitignore +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/LICENSE +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/docs/billing.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/AGENTS.md +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/CLAUDE.md +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/config/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/config/settings.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/config/urls.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/config/wsgi.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/manage.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/pyproject.toml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/billing.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/jobs.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/management/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/management/commands/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/management/commands/remind.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/migrations/0001_initial.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/migrations/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/models.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/reminders/views.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/specrig.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/checks.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/features/history.feature +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/features/reminders.feature +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/flows.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/internal/boot.feature +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/internal/harness.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/internal/test_cases.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/steps/history.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/steps/reminders.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/reminders/tests/surface.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/AGENTS.md +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/CLAUDE.md +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/alembic.ini +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/migrations/env.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/migrations/versions/0001_orders.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/pyproject.toml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/shop/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/shop/billing.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/shop/cli.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/shop/db.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/shop/jobs.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/shop/main.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/shop/settings.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/specrig.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/checks.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/features/orders.feature +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/features/placing.feature +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/flows.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/internal/boot.feature +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/internal/harness.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/internal/test_cases.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/steps/orders.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/steps/placing.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/samples/shop/tests/surface.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/__main__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/_yaml.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/asgi.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/clock.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/code/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/code/calls.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/code/django.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/code/fastapi.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/code/index.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/code/settings.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/contract.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/flows.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/gate.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/http.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/protocol/agents/contributing.md +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/protocol/agents/header.md +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/protocol/agents/reference.md +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/protocol/agents/rule11.md +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/protocol/agents/short.md +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/protocol/modes.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/proxy.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/py.typed +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/records.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/redis.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/resource.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/rig.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/run.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/standins.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/suite.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/surface.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/tool/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/tool/agents.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/tool/cli.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/tool/modes.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/src/specrig/tool/service.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/django/manage.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/django/pyproject.toml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/django/stock/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/django/stock/api.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/django/stock/settings.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/django/stock/urls.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/django/stock/views.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/django/stock/warehouse.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/fastapi/.env.example +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/config.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/crm.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/main.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/routes/__init__.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/routes/orders.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/sessions.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/fastapi/pyproject.toml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/protocol/agents/verdicts.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/protocol/markers/areas.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/protocol/records/reminders.flows.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/protocol/records/reminders.trace.json +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/protocol/records/shop.flows.yaml +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/fixtures/protocol/records/shop.trace.json +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_agents.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_flows.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_format.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_gate.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_rules.py +0 -0
- {specrig-0.3.0 → specrig-0.3.2}/tests/test_surface.py +0 -0
|
@@ -7,7 +7,7 @@ $(VENV):
|
|
|
7
7
|
$(VENV)/bin/pip install -q --upgrade pip
|
|
8
8
|
|
|
9
9
|
install: $(VENV)
|
|
10
|
-
$(VENV)/bin/pip install -q -e ".[fastapi,django,postgres]" --group dev
|
|
10
|
+
$(VENV)/bin/pip install -q -e ".[fastapi,django,postgres,mysql,sqlite]" --group dev
|
|
11
11
|
|
|
12
12
|
generate: install
|
|
13
13
|
cd samples/shop && $(VENV)/bin/specrig surface && $(VENV)/bin/specrig agents
|
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: specrig
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: A harness for a FastAPI or Django service developed specification first
|
|
5
|
+
Project-URL: Homepage, https://github.com/specrig/specrig-python
|
|
6
|
+
Project-URL: Protocol, https://github.com/specrig/protocol
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: jsonschema>=4.0
|
|
15
|
+
Requires-Dist: pytest-bdd>=8.1
|
|
16
|
+
Requires-Dist: pytest>=7.4
|
|
17
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
18
|
+
Requires-Dist: time-machine>=2.10
|
|
19
|
+
Requires-Dist: tomlkit>=0.11
|
|
20
|
+
Provides-Extra: django
|
|
21
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
22
|
+
Provides-Extra: fastapi
|
|
23
|
+
Requires-Dist: anyio>=4; extra == 'fastapi'
|
|
24
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
25
|
+
Provides-Extra: mysql
|
|
26
|
+
Requires-Dist: pymysql[rsa]>=1.1; extra == 'mysql'
|
|
27
|
+
Provides-Extra: postgres
|
|
28
|
+
Requires-Dist: psycopg[binary]>=3.1.8; extra == 'postgres'
|
|
29
|
+
Provides-Extra: sqlite
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
1
32
|
# specrig for Python
|
|
2
33
|
|
|
3
34
|
A harness for a FastAPI or Django service developed specification first: the
|
|
@@ -9,7 +40,7 @@ application.
|
|
|
9
40
|
**Status: built.** Everything below runs green on two samples — `shop`, a FastAPI
|
|
10
41
|
API over Postgres through SQLAlchemy and Alembic, with a job and a command, and
|
|
11
42
|
`reminders`, a Django service over Postgres and Redis with a job and a management
|
|
12
|
-
command. [docs/design.md](docs/design.md) has what each part is built from and
|
|
43
|
+
command; the tests run both again on MySQL and on SQLite. [docs/design.md](docs/design.md) has what each part is built from and
|
|
13
44
|
why; what every specrig stack agrees on — the records, the modes, the text of
|
|
14
45
|
`specrig.md` — is the [specrig protocol](https://github.com/specrig/protocol),
|
|
15
46
|
whose modes and `specrig.md` parts are copied into
|
|
@@ -67,7 +98,8 @@ In the service's directory, with its virtual environment in `.venv`:
|
|
|
67
98
|
.venv/bin/pip install "specrig[fastapi,postgres]"
|
|
68
99
|
```
|
|
69
100
|
|
|
70
|
-
or `specrig[django,postgres]
|
|
101
|
+
or `specrig[django,postgres]` — `mysql` or `sqlite` in place of `postgres` for a
|
|
102
|
+
service on those —, then:
|
|
71
103
|
|
|
72
104
|
```bash
|
|
73
105
|
.venv/bin/specrig init --backend billing=BILLING_URL --contract billing=../docs/billing.yaml
|
|
@@ -78,12 +110,19 @@ or `specrig[django,postgres]`, then:
|
|
|
78
110
|
dependencies. `--backend` names a neighbour the service calls over HTTP and the
|
|
79
111
|
variable that holds its address; a variable the code does not read is refused,
|
|
80
112
|
with the list of those it does, and a service that calls out with no `--backend`
|
|
81
|
-
is refused, naming the calls.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
113
|
+
is refused, naming the calls. The database the service reads from `DATABASE_URL`
|
|
114
|
+
becomes a resource by what says which it is: a Django settings module's `ENGINE`
|
|
115
|
+
first, then the server's driver the service depends on — a `Postgres` for psycopg
|
|
116
|
+
or asyncpg, a `MySQL` for mysqlclient, PyMySQL, asyncmy or aiomysql —, and a
|
|
117
|
+
`SQLite` only where nothing names a server and `.env.example` gives a `sqlite:`
|
|
118
|
+
address or the service depends on aiosqlite; with none of these there is no
|
|
119
|
+
database resource, and the variable is a constant like any other. The URL's scheme
|
|
120
|
+
comes from `.env.example` where it names the dialect chosen, else from the drivers,
|
|
121
|
+
an async one first — `postgresql+asyncpg`, `mysql+asyncmy` before `mysql+aiomysql`,
|
|
122
|
+
`mysql+pymysql`, `sqlite+aiosqlite`. Redis becomes a `Redis` where it depends on
|
|
123
|
+
`redis` and reads a variable that names it. Every other variable the code needs is
|
|
124
|
+
a constant the harness answers empty, and a Django service gets a `SECRET_KEY` of
|
|
125
|
+
its own.
|
|
87
126
|
|
|
88
127
|
| option | |
|
|
89
128
|
|---|---|
|
|
@@ -215,7 +254,9 @@ fixtures, autouse ones included, would change the service the cases see.
|
|
|
215
254
|
|---|---|
|
|
216
255
|
| `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
256
|
| `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>.
|
|
257
|
+
| `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 |
|
|
258
|
+
| `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 |
|
|
259
|
+
| `harness.<backend>.heal()` | the neighbour answers again: every `time_out` is lifted, the replies stay |
|
|
219
260
|
| `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
261
|
| `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
|
|
221
262
|
| `harness.run(job, *args, **kwargs)` | a job — see below |
|
|
@@ -224,11 +265,11 @@ fixtures, autouse ones included, would change the service the cases see.
|
|
|
224
265
|
| `harness.replace(target, instance)` | a granted class or function of the service, replaced before the service boots |
|
|
225
266
|
| `harness.logs` | what the service logged, under its real logging configuration |
|
|
226
267
|
| `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
|
|
227
|
-
| `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
|
|
228
|
-
| `harness.<
|
|
229
|
-
| `harness.<
|
|
230
|
-
| `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
|
|
231
|
-
| `harness.<
|
|
268
|
+
| `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres, MySQL and SQLite, and Redis |
|
|
269
|
+
| `harness.<database>.refuse(table, *writes)` | every insert, update and delete on the table, or those given, fails in the database itself, whoever sends it; reads go on working — see Postgres |
|
|
270
|
+
| `harness.<database>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
|
|
271
|
+
| `harness.<postgres>.refuse_commit(table, *writes)` | Postgres only: the statements go through, and the commit of a transaction that wrote the table fails |
|
|
272
|
+
| `harness.<database>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
|
|
232
273
|
| `harness.application` | FastAPI: the app the case runs |
|
|
233
274
|
|
|
234
275
|
The service boots at the first step that reaches it, so a `Given` declares and
|
|
@@ -281,7 +322,7 @@ in `settings = "config.settings"` where FastAPI's names `app`; the rest is the s
|
|
|
281
322
|
| member | |
|
|
282
323
|
|---|---|
|
|
283
324
|
| a `Backend` | an HTTP neighbour: its origin, the variable that points the service at it, its OpenAPI document, the schema of its 5xx |
|
|
284
|
-
| a `Postgres` or `Redis` | a real system, and the variable that receives its address |
|
|
325
|
+
| a `Postgres`, `MySQL`, `SQLite` or `Redis` | a real system, and the variable that receives its address |
|
|
285
326
|
| `app` | FastAPI: the application, `module:attribute`; a function there builds a new one for every case |
|
|
286
327
|
| `settings` | Django: the settings module |
|
|
287
328
|
| `console` | FastAPI: the service's console, `module:callable`, where `pyproject.toml` has more than one script |
|
|
@@ -343,7 +384,11 @@ database = Postgres("DATABASE_URL", scheme="postgresql+psycopg")
|
|
|
343
384
|
The variable receives one URL with the scheme given, `postgresql` by default. The
|
|
344
385
|
server is `address=` if given, else `<SERVICE>_TEST_POSTGRES`, else a container
|
|
345
386
|
started through the Docker socket and kept between runs — starting postgres costs
|
|
346
|
-
seconds, a database a fraction of one.
|
|
387
|
+
seconds, a database a fraction of one. Its role has no password, so the container
|
|
388
|
+
is published on loopback only — on every interface where
|
|
389
|
+
`TESTCONTAINERS_HOST_OVERRIDE` says the suite runs in a container of its own; a
|
|
390
|
+
container an older specrig started is reused as it was published. The database is
|
|
391
|
+
the run's own:
|
|
347
392
|
`shop_test_<pid>`, created at the first boot and dropped when the run ends. A run
|
|
348
393
|
killed before its end leaves its database behind, and the next run drops it: a
|
|
349
394
|
name of that shape, marked by specrig over an hour ago, with nobody connected.
|
|
@@ -369,7 +414,11 @@ resets.
|
|
|
369
414
|
`harness.database.fail_with()` closes the gate: every connection the service holds
|
|
370
415
|
is cut and every new one refused, as by a database that is down, and the service
|
|
371
416
|
meets its driver's own exception. An error given instead fails every statement
|
|
372
|
-
with it — through SQLAlchemy's events, or Django's execute wrappers.
|
|
417
|
+
with it — through SQLAlchemy's events, or Django's execute wrappers. An error of
|
|
418
|
+
the driver's DB-API reaches the service as the framework raises one the driver
|
|
419
|
+
raised: Django's `OperationalError` for psycopg's, SQLAlchemy's for SQLAlchemy —
|
|
420
|
+
which wraps it itself from 2.1, and the harness before. With asyncpg the DB-API is
|
|
421
|
+
SQLAlchemy's adapter, so asyncpg's own exceptions pass as given. A fault a
|
|
373
422
|
step sets before the boot waits for the service to start, so the migration and
|
|
374
423
|
the start go through; the next case starts whole.
|
|
375
424
|
|
|
@@ -390,6 +439,84 @@ refusals, before it writes the rows back. They live in a schema of their own,
|
|
|
390
439
|
`specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
|
|
391
440
|
see.
|
|
392
441
|
|
|
442
|
+
## MySQL and SQLite
|
|
443
|
+
|
|
444
|
+
```python
|
|
445
|
+
database = MySQL("DATABASE_URL", scheme="mysql+pymysql")
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
database = SQLite("DATABASE_URL", scheme="sqlite+aiosqlite")
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
The scheme is `mysql` and `sqlite` by default. Both are Postgres's resource with
|
|
453
|
+
what the dialect lacks worked around: the migrations once per run, the rows they
|
|
454
|
+
wrote put back before every case, `keep=`, `fail_with`, `refuse`, `refuse_after` and
|
|
455
|
+
`heal`. **There is no `refuse_commit`**: MySQL has no deferred constraint at all,
|
|
456
|
+
and the only thing that fails an SQLite commit on demand is a deferred foreign
|
|
457
|
+
key, which every driver raises as an `IntegrityError` — a case would test the
|
|
458
|
+
conflict branch instead of a failed commit.
|
|
459
|
+
|
|
460
|
+
**What a refusal raises is the dialect's.** A MySQL trigger refuses with
|
|
461
|
+
`SIGNAL SQLSTATE '45000'`, which every MySQL driver raises as an
|
|
462
|
+
`OperationalError`, the class of a lost connection: a service that answers an
|
|
463
|
+
`OperationalError` as a database down answers a refused write the same way, as
|
|
464
|
+
`shop` does with a 503. SQLite's `RAISE(ABORT)` is a failed constraint, so the
|
|
465
|
+
service meets an `IntegrityError`. Postgres's refusal is an `InternalError`.
|
|
466
|
+
|
|
467
|
+
### MySQL
|
|
468
|
+
|
|
469
|
+
The server is `address=`, else `<SERVICE>_TEST_MYSQL`, else a `mysql:8.4` container
|
|
470
|
+
started as root with no password and kept between runs, published as Postgres's
|
|
471
|
+
is. MySQL 8.0.19 and up: the tests run on 8.4, and the MySQL ones passed once
|
|
472
|
+
against 8.0.46. The database is the run's own, behind a gate, and a killed run's
|
|
473
|
+
is dropped by the next, as Postgres's. MySQL keeps no comment on a database, so the
|
|
474
|
+
mark is the comment of a `specrig_mark` table inside it; the copied rows sit in
|
|
475
|
+
`specrig_kept_<n>` tables beside the service's, which the reset never sees; and the
|
|
476
|
+
drop kills the connections left on the database first, since there is no
|
|
477
|
+
`WITH (FORCE)`. The harness talks to MySQL through PyMySQL, which the `mysql` extra
|
|
478
|
+
brings with `cryptography`: a user with a password over a connection without TLS
|
|
479
|
+
signs in with an RSA key. The service talks through the driver its URL names.
|
|
480
|
+
|
|
481
|
+
**A reset touches only what a case changed.** Truncating a table, creating a
|
|
482
|
+
trigger and setting a counter are DDL in MySQL. `CHECKSUM TABLE` over every table
|
|
483
|
+
in one statement finds the tables a case changed, and only those are emptied by
|
|
484
|
+
`DELETE` and written back, in one transaction, on the harness's own connection,
|
|
485
|
+
which checks no foreign keys and keeps an id 0 a migration wrote
|
|
486
|
+
(`NO_AUTO_VALUE_ON_ZERO`). An insert trigger of a table with rows to write back and
|
|
487
|
+
a delete trigger of a table with rows to delete are dropped first and created
|
|
488
|
+
again after, in their order and the `sql_mode` they were made in, and a trigger the
|
|
489
|
+
migrations made that a case dropped is made again; a counter is set back only where
|
|
490
|
+
it moved. Measured on a laptop, six tables:
|
|
491
|
+
|
|
492
|
+
| per reset | |
|
|
493
|
+
|---|---|
|
|
494
|
+
| nothing changed | ~3 ms |
|
|
495
|
+
| two rows inserted | 21–24 ms, most of them setting the two counters back |
|
|
496
|
+
| a row updated in a table with two triggers | 34–39 ms |
|
|
497
|
+
| every table truncated and the rows written back | 118–158 ms |
|
|
498
|
+
| Postgres, two rows inserted | ~7 ms |
|
|
499
|
+
|
|
500
|
+
### SQLite
|
|
501
|
+
|
|
502
|
+
**There is no address and no container.** The service is answered with a file of
|
|
503
|
+
the run's own, `sqlite:////…/specrig-…/shop.db` with the scheme given, in a
|
|
504
|
+
temporary directory the run removes at its end: nothing is shared between runs or
|
|
505
|
+
processes, and nothing is left to sweep. The copied rows are kept in a second file
|
|
506
|
+
that only the resetting connection attaches. SQLite cannot switch a trigger off, so
|
|
507
|
+
the reset drops every trigger and creates it again inside its transaction, with
|
|
508
|
+
foreign keys off on that connection, and puts `sqlite_sequence` back; it takes
|
|
509
|
+
under a millisecond. A virtual table — FTS5, R*Tree — and the tables it keeps
|
|
510
|
+
behind it are left as they are: their rows are not reset.
|
|
511
|
+
|
|
512
|
+
Whether the service's own connections check foreign keys is the framework's, as in
|
|
513
|
+
production: Django turns them on, SQLAlchemy leaves them off unless the service
|
|
514
|
+
turns them on.
|
|
515
|
+
|
|
516
|
+
`fail_with()` has no gate to close: every statement through SQLAlchemy or Django
|
|
517
|
+
fails with `sqlite3.OperationalError("unable to open database file")`, as from a
|
|
518
|
+
file that does not open, raised as the framework raises the driver's own.
|
|
519
|
+
|
|
393
520
|
## Redis
|
|
394
521
|
|
|
395
522
|
```python
|
|
@@ -523,7 +650,7 @@ the edits are let through, and the plugin says so once.
|
|
|
523
650
|
|
|
524
651
|
## Limits
|
|
525
652
|
|
|
526
|
-
- FastAPI 0.
|
|
653
|
+
- FastAPI 0.100 and up, and any ASGI application a module builds, through the
|
|
527
654
|
`fastapi` harness; Django 4.2 and up through its WSGI handler. Flask has no
|
|
528
655
|
harness.
|
|
529
656
|
- The service is imported once per run: what a module builds as it loads — a
|
|
@@ -545,7 +672,7 @@ the edits are let through, and the plugin says so once.
|
|
|
545
672
|
- The database is one URL in one variable. A Django service that sets its
|
|
546
673
|
`DATABASES` host by host from several variables is answered on the one the
|
|
547
674
|
harness names only.
|
|
548
|
-
- `Postgres` and `Redis` are the resources there are.
|
|
675
|
+
- `Postgres`, `MySQL`, `SQLite` and `Redis` are the resources there are.
|
|
549
676
|
- One service per run: two suites in one `pytest` process are refused, and a run
|
|
550
677
|
split by `pytest-xdist` writes no `tests/flows.yaml`, since no worker sees the
|
|
551
678
|
whole.
|
|
@@ -576,7 +703,7 @@ run against copies of the samples, then the suites of the two samples in
|
|
|
576
703
|
docker rm -f $(docker ps -aq --filter label=specrig=postgres)
|
|
577
704
|
```
|
|
578
705
|
|
|
579
|
-
removes the kept Postgres container.
|
|
706
|
+
removes the kept Postgres container, and `label=specrig=mysql` the MySQL one.
|
|
580
707
|
|
|
581
708
|
```bash
|
|
582
709
|
make protocol
|
|
@@ -1,31 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: specrig
|
|
3
|
-
Version: 0.3.0
|
|
4
|
-
Summary: A harness for a FastAPI or Django service developed specification first
|
|
5
|
-
Project-URL: Homepage, https://github.com/specrig/specrig-python
|
|
6
|
-
Project-URL: Protocol, https://github.com/specrig/protocol
|
|
7
|
-
License-Expression: MIT
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Classifier: Framework :: Django
|
|
10
|
-
Classifier: Framework :: FastAPI
|
|
11
|
-
Classifier: Framework :: Pytest
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Requires-Python: >=3.11
|
|
14
|
-
Requires-Dist: jsonschema>=4.0
|
|
15
|
-
Requires-Dist: pytest-bdd>=8.1
|
|
16
|
-
Requires-Dist: pytest>=7.4
|
|
17
|
-
Requires-Dist: pyyaml>=6.0.1
|
|
18
|
-
Requires-Dist: time-machine>=2.10
|
|
19
|
-
Requires-Dist: tomlkit>=0.11
|
|
20
|
-
Provides-Extra: django
|
|
21
|
-
Requires-Dist: django>=4.2; extra == 'django'
|
|
22
|
-
Provides-Extra: fastapi
|
|
23
|
-
Requires-Dist: anyio>=4; extra == 'fastapi'
|
|
24
|
-
Requires-Dist: fastapi>=0.90; extra == 'fastapi'
|
|
25
|
-
Provides-Extra: postgres
|
|
26
|
-
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
27
|
-
Description-Content-Type: text/markdown
|
|
28
|
-
|
|
29
1
|
# specrig for Python
|
|
30
2
|
|
|
31
3
|
A harness for a FastAPI or Django service developed specification first: the
|
|
@@ -37,7 +9,7 @@ application.
|
|
|
37
9
|
**Status: built.** Everything below runs green on two samples — `shop`, a FastAPI
|
|
38
10
|
API over Postgres through SQLAlchemy and Alembic, with a job and a command, and
|
|
39
11
|
`reminders`, a Django service over Postgres and Redis with a job and a management
|
|
40
|
-
command. [docs/design.md](docs/design.md) has what each part is built from and
|
|
12
|
+
command; the tests run both again on MySQL and on SQLite. [docs/design.md](docs/design.md) has what each part is built from and
|
|
41
13
|
why; what every specrig stack agrees on — the records, the modes, the text of
|
|
42
14
|
`specrig.md` — is the [specrig protocol](https://github.com/specrig/protocol),
|
|
43
15
|
whose modes and `specrig.md` parts are copied into
|
|
@@ -95,7 +67,8 @@ In the service's directory, with its virtual environment in `.venv`:
|
|
|
95
67
|
.venv/bin/pip install "specrig[fastapi,postgres]"
|
|
96
68
|
```
|
|
97
69
|
|
|
98
|
-
or `specrig[django,postgres]
|
|
70
|
+
or `specrig[django,postgres]` — `mysql` or `sqlite` in place of `postgres` for a
|
|
71
|
+
service on those —, then:
|
|
99
72
|
|
|
100
73
|
```bash
|
|
101
74
|
.venv/bin/specrig init --backend billing=BILLING_URL --contract billing=../docs/billing.yaml
|
|
@@ -106,12 +79,19 @@ or `specrig[django,postgres]`, then:
|
|
|
106
79
|
dependencies. `--backend` names a neighbour the service calls over HTTP and the
|
|
107
80
|
variable that holds its address; a variable the code does not read is refused,
|
|
108
81
|
with the list of those it does, and a service that calls out with no `--backend`
|
|
109
|
-
is refused, naming the calls.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
82
|
+
is refused, naming the calls. The database the service reads from `DATABASE_URL`
|
|
83
|
+
becomes a resource by what says which it is: a Django settings module's `ENGINE`
|
|
84
|
+
first, then the server's driver the service depends on — a `Postgres` for psycopg
|
|
85
|
+
or asyncpg, a `MySQL` for mysqlclient, PyMySQL, asyncmy or aiomysql —, and a
|
|
86
|
+
`SQLite` only where nothing names a server and `.env.example` gives a `sqlite:`
|
|
87
|
+
address or the service depends on aiosqlite; with none of these there is no
|
|
88
|
+
database resource, and the variable is a constant like any other. The URL's scheme
|
|
89
|
+
comes from `.env.example` where it names the dialect chosen, else from the drivers,
|
|
90
|
+
an async one first — `postgresql+asyncpg`, `mysql+asyncmy` before `mysql+aiomysql`,
|
|
91
|
+
`mysql+pymysql`, `sqlite+aiosqlite`. Redis becomes a `Redis` where it depends on
|
|
92
|
+
`redis` and reads a variable that names it. Every other variable the code needs is
|
|
93
|
+
a constant the harness answers empty, and a Django service gets a `SECRET_KEY` of
|
|
94
|
+
its own.
|
|
115
95
|
|
|
116
96
|
| option | |
|
|
117
97
|
|---|---|
|
|
@@ -243,7 +223,9 @@ fixtures, autouse ones included, would change the service the cases see.
|
|
|
243
223
|
|---|---|
|
|
244
224
|
| `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
225
|
| `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>.
|
|
226
|
+
| `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 |
|
|
227
|
+
| `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 |
|
|
228
|
+
| `harness.<backend>.heal()` | the neighbour answers again: every `time_out` is lifted, the replies stay |
|
|
247
229
|
| `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
230
|
| `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
|
|
249
231
|
| `harness.run(job, *args, **kwargs)` | a job — see below |
|
|
@@ -252,11 +234,11 @@ fixtures, autouse ones included, would change the service the cases see.
|
|
|
252
234
|
| `harness.replace(target, instance)` | a granted class or function of the service, replaced before the service boots |
|
|
253
235
|
| `harness.logs` | what the service logged, under its real logging configuration |
|
|
254
236
|
| `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
|
|
255
|
-
| `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
|
|
256
|
-
| `harness.<
|
|
257
|
-
| `harness.<
|
|
258
|
-
| `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
|
|
259
|
-
| `harness.<
|
|
237
|
+
| `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres, MySQL and SQLite, and Redis |
|
|
238
|
+
| `harness.<database>.refuse(table, *writes)` | every insert, update and delete on the table, or those given, fails in the database itself, whoever sends it; reads go on working — see Postgres |
|
|
239
|
+
| `harness.<database>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
|
|
240
|
+
| `harness.<postgres>.refuse_commit(table, *writes)` | Postgres only: the statements go through, and the commit of a transaction that wrote the table fails |
|
|
241
|
+
| `harness.<database>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
|
|
260
242
|
| `harness.application` | FastAPI: the app the case runs |
|
|
261
243
|
|
|
262
244
|
The service boots at the first step that reaches it, so a `Given` declares and
|
|
@@ -309,7 +291,7 @@ in `settings = "config.settings"` where FastAPI's names `app`; the rest is the s
|
|
|
309
291
|
| member | |
|
|
310
292
|
|---|---|
|
|
311
293
|
| a `Backend` | an HTTP neighbour: its origin, the variable that points the service at it, its OpenAPI document, the schema of its 5xx |
|
|
312
|
-
| a `Postgres` or `Redis` | a real system, and the variable that receives its address |
|
|
294
|
+
| a `Postgres`, `MySQL`, `SQLite` or `Redis` | a real system, and the variable that receives its address |
|
|
313
295
|
| `app` | FastAPI: the application, `module:attribute`; a function there builds a new one for every case |
|
|
314
296
|
| `settings` | Django: the settings module |
|
|
315
297
|
| `console` | FastAPI: the service's console, `module:callable`, where `pyproject.toml` has more than one script |
|
|
@@ -371,7 +353,11 @@ database = Postgres("DATABASE_URL", scheme="postgresql+psycopg")
|
|
|
371
353
|
The variable receives one URL with the scheme given, `postgresql` by default. The
|
|
372
354
|
server is `address=` if given, else `<SERVICE>_TEST_POSTGRES`, else a container
|
|
373
355
|
started through the Docker socket and kept between runs — starting postgres costs
|
|
374
|
-
seconds, a database a fraction of one.
|
|
356
|
+
seconds, a database a fraction of one. Its role has no password, so the container
|
|
357
|
+
is published on loopback only — on every interface where
|
|
358
|
+
`TESTCONTAINERS_HOST_OVERRIDE` says the suite runs in a container of its own; a
|
|
359
|
+
container an older specrig started is reused as it was published. The database is
|
|
360
|
+
the run's own:
|
|
375
361
|
`shop_test_<pid>`, created at the first boot and dropped when the run ends. A run
|
|
376
362
|
killed before its end leaves its database behind, and the next run drops it: a
|
|
377
363
|
name of that shape, marked by specrig over an hour ago, with nobody connected.
|
|
@@ -397,7 +383,11 @@ resets.
|
|
|
397
383
|
`harness.database.fail_with()` closes the gate: every connection the service holds
|
|
398
384
|
is cut and every new one refused, as by a database that is down, and the service
|
|
399
385
|
meets its driver's own exception. An error given instead fails every statement
|
|
400
|
-
with it — through SQLAlchemy's events, or Django's execute wrappers.
|
|
386
|
+
with it — through SQLAlchemy's events, or Django's execute wrappers. An error of
|
|
387
|
+
the driver's DB-API reaches the service as the framework raises one the driver
|
|
388
|
+
raised: Django's `OperationalError` for psycopg's, SQLAlchemy's for SQLAlchemy —
|
|
389
|
+
which wraps it itself from 2.1, and the harness before. With asyncpg the DB-API is
|
|
390
|
+
SQLAlchemy's adapter, so asyncpg's own exceptions pass as given. A fault a
|
|
401
391
|
step sets before the boot waits for the service to start, so the migration and
|
|
402
392
|
the start go through; the next case starts whole.
|
|
403
393
|
|
|
@@ -418,6 +408,84 @@ refusals, before it writes the rows back. They live in a schema of their own,
|
|
|
418
408
|
`specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
|
|
419
409
|
see.
|
|
420
410
|
|
|
411
|
+
## MySQL and SQLite
|
|
412
|
+
|
|
413
|
+
```python
|
|
414
|
+
database = MySQL("DATABASE_URL", scheme="mysql+pymysql")
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
database = SQLite("DATABASE_URL", scheme="sqlite+aiosqlite")
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
The scheme is `mysql` and `sqlite` by default. Both are Postgres's resource with
|
|
422
|
+
what the dialect lacks worked around: the migrations once per run, the rows they
|
|
423
|
+
wrote put back before every case, `keep=`, `fail_with`, `refuse`, `refuse_after` and
|
|
424
|
+
`heal`. **There is no `refuse_commit`**: MySQL has no deferred constraint at all,
|
|
425
|
+
and the only thing that fails an SQLite commit on demand is a deferred foreign
|
|
426
|
+
key, which every driver raises as an `IntegrityError` — a case would test the
|
|
427
|
+
conflict branch instead of a failed commit.
|
|
428
|
+
|
|
429
|
+
**What a refusal raises is the dialect's.** A MySQL trigger refuses with
|
|
430
|
+
`SIGNAL SQLSTATE '45000'`, which every MySQL driver raises as an
|
|
431
|
+
`OperationalError`, the class of a lost connection: a service that answers an
|
|
432
|
+
`OperationalError` as a database down answers a refused write the same way, as
|
|
433
|
+
`shop` does with a 503. SQLite's `RAISE(ABORT)` is a failed constraint, so the
|
|
434
|
+
service meets an `IntegrityError`. Postgres's refusal is an `InternalError`.
|
|
435
|
+
|
|
436
|
+
### MySQL
|
|
437
|
+
|
|
438
|
+
The server is `address=`, else `<SERVICE>_TEST_MYSQL`, else a `mysql:8.4` container
|
|
439
|
+
started as root with no password and kept between runs, published as Postgres's
|
|
440
|
+
is. MySQL 8.0.19 and up: the tests run on 8.4, and the MySQL ones passed once
|
|
441
|
+
against 8.0.46. The database is the run's own, behind a gate, and a killed run's
|
|
442
|
+
is dropped by the next, as Postgres's. MySQL keeps no comment on a database, so the
|
|
443
|
+
mark is the comment of a `specrig_mark` table inside it; the copied rows sit in
|
|
444
|
+
`specrig_kept_<n>` tables beside the service's, which the reset never sees; and the
|
|
445
|
+
drop kills the connections left on the database first, since there is no
|
|
446
|
+
`WITH (FORCE)`. The harness talks to MySQL through PyMySQL, which the `mysql` extra
|
|
447
|
+
brings with `cryptography`: a user with a password over a connection without TLS
|
|
448
|
+
signs in with an RSA key. The service talks through the driver its URL names.
|
|
449
|
+
|
|
450
|
+
**A reset touches only what a case changed.** Truncating a table, creating a
|
|
451
|
+
trigger and setting a counter are DDL in MySQL. `CHECKSUM TABLE` over every table
|
|
452
|
+
in one statement finds the tables a case changed, and only those are emptied by
|
|
453
|
+
`DELETE` and written back, in one transaction, on the harness's own connection,
|
|
454
|
+
which checks no foreign keys and keeps an id 0 a migration wrote
|
|
455
|
+
(`NO_AUTO_VALUE_ON_ZERO`). An insert trigger of a table with rows to write back and
|
|
456
|
+
a delete trigger of a table with rows to delete are dropped first and created
|
|
457
|
+
again after, in their order and the `sql_mode` they were made in, and a trigger the
|
|
458
|
+
migrations made that a case dropped is made again; a counter is set back only where
|
|
459
|
+
it moved. Measured on a laptop, six tables:
|
|
460
|
+
|
|
461
|
+
| per reset | |
|
|
462
|
+
|---|---|
|
|
463
|
+
| nothing changed | ~3 ms |
|
|
464
|
+
| two rows inserted | 21–24 ms, most of them setting the two counters back |
|
|
465
|
+
| a row updated in a table with two triggers | 34–39 ms |
|
|
466
|
+
| every table truncated and the rows written back | 118–158 ms |
|
|
467
|
+
| Postgres, two rows inserted | ~7 ms |
|
|
468
|
+
|
|
469
|
+
### SQLite
|
|
470
|
+
|
|
471
|
+
**There is no address and no container.** The service is answered with a file of
|
|
472
|
+
the run's own, `sqlite:////…/specrig-…/shop.db` with the scheme given, in a
|
|
473
|
+
temporary directory the run removes at its end: nothing is shared between runs or
|
|
474
|
+
processes, and nothing is left to sweep. The copied rows are kept in a second file
|
|
475
|
+
that only the resetting connection attaches. SQLite cannot switch a trigger off, so
|
|
476
|
+
the reset drops every trigger and creates it again inside its transaction, with
|
|
477
|
+
foreign keys off on that connection, and puts `sqlite_sequence` back; it takes
|
|
478
|
+
under a millisecond. A virtual table — FTS5, R*Tree — and the tables it keeps
|
|
479
|
+
behind it are left as they are: their rows are not reset.
|
|
480
|
+
|
|
481
|
+
Whether the service's own connections check foreign keys is the framework's, as in
|
|
482
|
+
production: Django turns them on, SQLAlchemy leaves them off unless the service
|
|
483
|
+
turns them on.
|
|
484
|
+
|
|
485
|
+
`fail_with()` has no gate to close: every statement through SQLAlchemy or Django
|
|
486
|
+
fails with `sqlite3.OperationalError("unable to open database file")`, as from a
|
|
487
|
+
file that does not open, raised as the framework raises the driver's own.
|
|
488
|
+
|
|
421
489
|
## Redis
|
|
422
490
|
|
|
423
491
|
```python
|
|
@@ -551,7 +619,7 @@ the edits are let through, and the plugin says so once.
|
|
|
551
619
|
|
|
552
620
|
## Limits
|
|
553
621
|
|
|
554
|
-
- FastAPI 0.
|
|
622
|
+
- FastAPI 0.100 and up, and any ASGI application a module builds, through the
|
|
555
623
|
`fastapi` harness; Django 4.2 and up through its WSGI handler. Flask has no
|
|
556
624
|
harness.
|
|
557
625
|
- The service is imported once per run: what a module builds as it loads — a
|
|
@@ -573,7 +641,7 @@ the edits are let through, and the plugin says so once.
|
|
|
573
641
|
- The database is one URL in one variable. A Django service that sets its
|
|
574
642
|
`DATABASES` host by host from several variables is answered on the one the
|
|
575
643
|
harness names only.
|
|
576
|
-
- `Postgres` and `Redis` are the resources there are.
|
|
644
|
+
- `Postgres`, `MySQL`, `SQLite` and `Redis` are the resources there are.
|
|
577
645
|
- One service per run: two suites in one `pytest` process are refused, and a run
|
|
578
646
|
split by `pytest-xdist` writes no `tests/flows.yaml`, since no worker sees the
|
|
579
647
|
whole.
|
|
@@ -604,7 +672,7 @@ run against copies of the samples, then the suites of the two samples in
|
|
|
604
672
|
docker rm -f $(docker ps -aq --filter label=specrig=postgres)
|
|
605
673
|
```
|
|
606
674
|
|
|
607
|
-
removes the kept Postgres container.
|
|
675
|
+
removes the kept Postgres container, and `label=specrig=mysql` the MySQL one.
|
|
608
676
|
|
|
609
677
|
```bash
|
|
610
678
|
make protocol
|
|
@@ -57,7 +57,9 @@ migrations write — the states an order moves through, the root of a tree — e
|
|
|
57
57
|
in no environment, a step that writes them again is a second copy of the
|
|
58
58
|
migration that drifts from the first, and a `keep` for them carries one case's
|
|
59
59
|
changes into the next. Writing them back turns foreign keys and triggers off for
|
|
60
|
-
one transaction, which takes a superuser; migrations that write no rows
|
|
60
|
+
one transaction, which takes a superuser on Postgres; migrations that write no rows
|
|
61
|
+
need none. MySQL's foreign key checks are a session's and SQLite's a connection's,
|
|
62
|
+
and the triggers on both are dropped and created again instead.
|
|
61
63
|
|
|
62
64
|
## One request at a time
|
|
63
65
|
|
|
@@ -83,6 +85,8 @@ flight between steps, and `time_out` stands in for a slow neighbour, as in PHP.
|
|
|
83
85
|
| logs | a handler on the root logger, beside the service's | the same |
|
|
84
86
|
| 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
87
|
| 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 |
|
|
88
|
+
| MySQL | as Postgres, but only the tables whose `CHECKSUM TABLE` moved are emptied by `DELETE` and written back, with the triggers those writes would fire dropped and created again in their order, a trigger a case dropped made again, and a counter set back only where it moved; a refusal by the gate, a table's writes by triggers that `SIGNAL` | as Postgres, with the same reset |
|
|
89
|
+
| SQLite | a file of the run's own in a temporary directory; before every case every table but the virtual ones emptied and written back from a second file the resetting connection attaches, every trigger dropped and created again, `sqlite_sequence` put back; a refusal and an error alike by SQLAlchemy's `before_cursor_execute`; a table's writes by triggers that `RAISE(ABORT)` | the same, through the connections' execute wrappers |
|
|
86
90
|
| Redis | `FLUSHDB` before every case; a refusal by the gate | the same |
|
|
87
91
|
| 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
92
|
| `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 |
|
|
@@ -126,8 +130,8 @@ and `requests`' `HTTPAdapter`. Every other client meets the proxy.
|
|
|
126
130
|
|
|
127
131
|
**A template names what a step answers, not what the service sent.** As in
|
|
128
132
|
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`, `
|
|
130
|
-
`requests` all take one. The contract check and the trace go on naming a request
|
|
133
|
+
and of two templates the first declared; `reply`, `reply_to`, `reply_with`,
|
|
134
|
+
`time_out` and `requests` all take one. The contract check and the trace go on naming a request
|
|
131
135
|
by the path it came with, so a template that matches too much is not hidden by
|
|
132
136
|
the contract it is checked against. A reply's headers are added to the response
|
|
133
137
|
as the transports hand it to the client, so `httpx`, `httpx2` and `requests` see
|
|
@@ -148,6 +152,50 @@ triggers, and the quota that lets `refuse_after`'s rows through, live in a
|
|
|
148
152
|
copies, and the reset drops it first. A refusal a step sets before the boot waits
|
|
149
153
|
for the start, since the reset would lift it and the schema may not exist yet.
|
|
150
154
|
|
|
155
|
+
**One engine, a server half, a dialect each.** As in gorib's `sqltest`: what every
|
|
156
|
+
database does — migrate once, keep the rows, put them back before every case, the
|
|
157
|
+
steps' faults and refusals — is `database.py`; the address chain, the container,
|
|
158
|
+
the database of the run's own, the gate and the sweep are `server.py`, which
|
|
159
|
+
Postgres and MySQL share; each dialect is its own SQL. SQLite has no server half,
|
|
160
|
+
since a file needs none.
|
|
161
|
+
|
|
162
|
+
**MySQL puts back only what a case changed.** Truncating a table, creating a
|
|
163
|
+
trigger and setting a counter are DDL there: measured on a laptop, ~16 ms for a
|
|
164
|
+
`TRUNCATE`, ~3 ms for a `CREATE TRIGGER`, 3 to 7 ms for an `ALTER TABLE …
|
|
165
|
+
AUTO_INCREMENT`, against ~0.2 ms for a `DELETE` in a transaction. So the reset finds
|
|
166
|
+
the tables a case changed by `CHECKSUM TABLE`, one statement for all of them, and
|
|
167
|
+
touches nothing else: ~3 ms when nothing changed, against 118–158 ms for truncating
|
|
168
|
+
six tables and writing their rows back. The triggers it drops are those its own writes
|
|
169
|
+
would fire, created again in the `sql_mode` they were made in.
|
|
170
|
+
|
|
171
|
+
**The harness's MySQL connection checks no foreign keys.** gorib's `sqltest` resets
|
|
172
|
+
on a connection it closes afterwards, so that one with the checks off never goes
|
|
173
|
+
back to the pool the service shares. Here the harness's connection is its own and
|
|
174
|
+
never the service's, so it stays open with the checks off: opening one costs ~8 ms,
|
|
175
|
+
three times an untouched reset.
|
|
176
|
+
|
|
177
|
+
**No `refuse_commit` on MySQL or SQLite.** MySQL has no deferred constraint at all;
|
|
178
|
+
SQLite fails a commit on demand only through a deferred foreign key, which every
|
|
179
|
+
driver raises as an `IntegrityError`, so a case would test the conflict branch
|
|
180
|
+
instead of a failed commit. The method is absent rather than failing when called.
|
|
181
|
+
|
|
182
|
+
**SQLite's outage is an error, not a gate.** A file has no connection to cut, so
|
|
183
|
+
`fail_with()` fails every statement through the frameworks' hooks with the driver's
|
|
184
|
+
own `unable to open database file`. A driver's error reaches the service as the
|
|
185
|
+
framework raises the driver's own, Postgres's and MySQL's alike. Django's execute
|
|
186
|
+
wrappers run outside its error wrapper, so the hook raises inside it, and Django
|
|
187
|
+
marks the connection as a real failure would. SQLAlchemy before 2.1 lets an event's
|
|
188
|
+
error through as it is, so the hook wraps a DB-API error there; from 2.1 SQLAlchemy
|
|
189
|
+
handles the raw error as one the driver raised — the disconnect check, the pool's
|
|
190
|
+
invalidation, `handle_error` — and the hook leaves it to it.
|
|
191
|
+
|
|
192
|
+
**A dialect from evidence, not by elimination.** `init` takes the database from a
|
|
193
|
+
Django settings module's `ENGINE`, else from a server's driver, and gives SQLite
|
|
194
|
+
only on a `sqlite:` address in `.env.example` or aiosqlite: a Django or SQLAlchemy
|
|
195
|
+
service with no driver it can see is not taken for one on SQLite, and a
|
|
196
|
+
`sqlite:///dev.db` a Postgres service keeps for its developers does not turn it
|
|
197
|
+
into one.
|
|
198
|
+
|
|
151
199
|
**Alembic in a process of its own.** Alembic's `env.py` configures logging as it
|
|
152
200
|
runs, disabling the loggers the service already made; production runs the
|
|
153
201
|
migration as a job of its own, and so does the harness.
|
|
@@ -170,14 +218,17 @@ libraries.
|
|
|
170
218
|
|
|
171
219
|
## The owner's calls
|
|
172
220
|
|
|
173
|
-
1. **Python and the frameworks.** Python 3.11 and up; FastAPI 0.
|
|
174
|
-
ASGI app a module builds; Django 4.2 and up
|
|
175
|
-
|
|
221
|
+
1. **Python and the frameworks.** Python 3.11 and up; FastAPI 0.100 and up, the
|
|
222
|
+
first on pydantic 2, and any ASGI app a module builds; Django 4.2 and up, with
|
|
223
|
+
psycopg 3.1.8 and up, which Django 4.2 asks for. Not the newest, and not what
|
|
224
|
+
nobody runs any more.
|
|
176
225
|
2. **pytest-bdd.** 8.1 and up, and pytest 7.4 and up.
|
|
177
226
|
3. **No bus yet.** RabbitMQ, Celery and Django's tasks are a later release.
|
|
178
227
|
4. **gRPC.** Neither way.
|
|
179
228
|
5. **Enforcement in Claude Code.** As in .NET and PHP: `rules` prints the
|
|
180
229
|
service's rules, and the plugin checks every edit against them.
|
|
230
|
+
6. **Databases.** Postgres, MySQL and SQLite. The frameworks' own scaffolds stay
|
|
231
|
+
as they are; the harness adds its dialects.
|
|
181
232
|
|
|
182
233
|
## Order, and where it stands
|
|
183
234
|
|
|
@@ -186,5 +237,5 @@ libraries.
|
|
|
186
237
|
| 0 | the records and the fixtures | built |
|
|
187
238
|
| 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 |
|
|
188
239
|
| 2 | `tests/surface.yaml` and `tests/checks.yaml`, `init`, `rules`, `specrig.md` | built |
|
|
189
|
-
| 3 | Redis, grants, `fail_with`, a refused table and `heal`, reply headers and path templates | built |
|
|
240
|
+
| 3 | Redis, grants, `fail_with`, a refused table and `heal`, reply headers and path templates; MySQL and SQLite | built |
|
|
190
241
|
| 4 | a bus and a queue | not started |
|