specrig 0.3.1__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.1 → specrig-0.3.2}/Makefile +1 -1
- specrig-0.3.1/README.md → specrig-0.3.2/PKG-INFO +144 -19
- specrig-0.3.1/PKG-INFO → specrig-0.3.2/README.md +113 -47
- {specrig-0.3.1 → specrig-0.3.2}/docs/design.md +56 -5
- {specrig-0.3.1 → specrig-0.3.2}/pyproject.toml +10 -4
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/specrig.md +4 -4
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/specrig.md +4 -4
- specrig-0.3.2/src/specrig/__init__.py +10 -0
- specrig-0.3.2/src/specrig/database.py +165 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/django/__init__.py +2 -1
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/docker.py +5 -1
- {specrig-0.3.1 → 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.1 → specrig-0.3.2}/src/specrig/tool/init.py +64 -11
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/templates.py +3 -3
- specrig-0.3.2/tests/test_docker.py +39 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/test_harness.py +191 -1
- {specrig-0.3.1 → specrig-0.3.2}/tests/test_init.py +116 -0
- specrig-0.3.2/tests/test_mysql.py +612 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/test_postgres.py +41 -0
- specrig-0.3.2/tests/test_sqlite.py +426 -0
- specrig-0.3.1/src/specrig/__init__.py +0 -7
- specrig-0.3.1/src/specrig/postgres.py +0 -435
- {specrig-0.3.1 → specrig-0.3.2}/.github/workflows/release.yml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/.gitignore +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/LICENSE +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/docs/billing.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/AGENTS.md +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/CLAUDE.md +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/config/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/config/settings.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/config/urls.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/config/wsgi.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/manage.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/pyproject.toml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/billing.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/jobs.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/management/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/management/commands/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/management/commands/remind.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/migrations/0001_initial.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/migrations/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/models.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/views.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/specrig.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/checks.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/features/history.feature +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/features/reminders.feature +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/flows.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/internal/boot.feature +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/internal/harness.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/internal/test_cases.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/steps/history.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/steps/reminders.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/surface.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/AGENTS.md +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/CLAUDE.md +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/alembic.ini +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/migrations/env.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/migrations/versions/0001_orders.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/pyproject.toml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/billing.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/cli.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/db.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/jobs.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/main.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/settings.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/specrig.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/checks.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/features/orders.feature +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/features/placing.feature +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/flows.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/internal/boot.feature +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/internal/harness.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/internal/test_cases.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/steps/orders.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/steps/placing.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/surface.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/__main__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/_yaml.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/asgi.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/backend.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/clock.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/calls.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/django.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/fastapi.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/index.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/settings.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/contract.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/flows.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/gate.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/http.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/contributing.md +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/header.md +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/reference.md +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/rule11.md +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/short.md +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/modes.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/proxy.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/py.typed +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/records.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/redis.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/resource.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/rig.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/run.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/standins.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/suite.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/surface.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/agents.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/cli.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/modes.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/service.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/manage.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/pyproject.toml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/api.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/settings.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/urls.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/views.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/warehouse.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/.env.example +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/config.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/crm.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/main.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/routes/__init__.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/routes/orders.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/sessions.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/pyproject.toml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/agents/verdicts.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/markers/areas.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/records/reminders.flows.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/records/reminders.trace.json +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/records/shop.flows.yaml +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/records/shop.trace.json +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/test_agents.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/test_backend.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/test_flows.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/test_format.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/test_gate.py +0 -0
- {specrig-0.3.1 → specrig-0.3.2}/tests/test_rules.py +0 -0
- {specrig-0.3.1 → 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
|
|---|---|
|
|
@@ -226,11 +265,11 @@ fixtures, autouse ones included, would change the service the cases see.
|
|
|
226
265
|
| `harness.replace(target, instance)` | a granted class or function of the service, replaced before the service boots |
|
|
227
266
|
| `harness.logs` | what the service logged, under its real logging configuration |
|
|
228
267
|
| `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
|
|
229
|
-
| `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
|
|
230
|
-
| `harness.<
|
|
231
|
-
| `harness.<
|
|
232
|
-
| `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
|
|
233
|
-
| `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 |
|
|
234
273
|
| `harness.application` | FastAPI: the app the case runs |
|
|
235
274
|
|
|
236
275
|
The service boots at the first step that reaches it, so a `Given` declares and
|
|
@@ -283,7 +322,7 @@ in `settings = "config.settings"` where FastAPI's names `app`; the rest is the s
|
|
|
283
322
|
| member | |
|
|
284
323
|
|---|---|
|
|
285
324
|
| a `Backend` | an HTTP neighbour: its origin, the variable that points the service at it, its OpenAPI document, the schema of its 5xx |
|
|
286
|
-
| 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 |
|
|
287
326
|
| `app` | FastAPI: the application, `module:attribute`; a function there builds a new one for every case |
|
|
288
327
|
| `settings` | Django: the settings module |
|
|
289
328
|
| `console` | FastAPI: the service's console, `module:callable`, where `pyproject.toml` has more than one script |
|
|
@@ -345,7 +384,11 @@ database = Postgres("DATABASE_URL", scheme="postgresql+psycopg")
|
|
|
345
384
|
The variable receives one URL with the scheme given, `postgresql` by default. The
|
|
346
385
|
server is `address=` if given, else `<SERVICE>_TEST_POSTGRES`, else a container
|
|
347
386
|
started through the Docker socket and kept between runs — starting postgres costs
|
|
348
|
-
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:
|
|
349
392
|
`shop_test_<pid>`, created at the first boot and dropped when the run ends. A run
|
|
350
393
|
killed before its end leaves its database behind, and the next run drops it: a
|
|
351
394
|
name of that shape, marked by specrig over an hour ago, with nobody connected.
|
|
@@ -371,7 +414,11 @@ resets.
|
|
|
371
414
|
`harness.database.fail_with()` closes the gate: every connection the service holds
|
|
372
415
|
is cut and every new one refused, as by a database that is down, and the service
|
|
373
416
|
meets its driver's own exception. An error given instead fails every statement
|
|
374
|
-
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
|
|
375
422
|
step sets before the boot waits for the service to start, so the migration and
|
|
376
423
|
the start go through; the next case starts whole.
|
|
377
424
|
|
|
@@ -392,6 +439,84 @@ refusals, before it writes the rows back. They live in a schema of their own,
|
|
|
392
439
|
`specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
|
|
393
440
|
see.
|
|
394
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
|
+
|
|
395
520
|
## Redis
|
|
396
521
|
|
|
397
522
|
```python
|
|
@@ -525,7 +650,7 @@ the edits are let through, and the plugin says so once.
|
|
|
525
650
|
|
|
526
651
|
## Limits
|
|
527
652
|
|
|
528
|
-
- FastAPI 0.
|
|
653
|
+
- FastAPI 0.100 and up, and any ASGI application a module builds, through the
|
|
529
654
|
`fastapi` harness; Django 4.2 and up through its WSGI handler. Flask has no
|
|
530
655
|
harness.
|
|
531
656
|
- The service is imported once per run: what a module builds as it loads — a
|
|
@@ -547,7 +672,7 @@ the edits are let through, and the plugin says so once.
|
|
|
547
672
|
- The database is one URL in one variable. A Django service that sets its
|
|
548
673
|
`DATABASES` host by host from several variables is answered on the one the
|
|
549
674
|
harness names only.
|
|
550
|
-
- `Postgres` and `Redis` are the resources there are.
|
|
675
|
+
- `Postgres`, `MySQL`, `SQLite` and `Redis` are the resources there are.
|
|
551
676
|
- One service per run: two suites in one `pytest` process are refused, and a run
|
|
552
677
|
split by `pytest-xdist` writes no `tests/flows.yaml`, since no worker sees the
|
|
553
678
|
whole.
|
|
@@ -578,7 +703,7 @@ run against copies of the samples, then the suites of the two samples in
|
|
|
578
703
|
docker rm -f $(docker ps -aq --filter label=specrig=postgres)
|
|
579
704
|
```
|
|
580
705
|
|
|
581
|
-
removes the kept Postgres container.
|
|
706
|
+
removes the kept Postgres container, and `label=specrig=mysql` the MySQL one.
|
|
582
707
|
|
|
583
708
|
```bash
|
|
584
709
|
make protocol
|
|
@@ -1,31 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: specrig
|
|
3
|
-
Version: 0.3.1
|
|
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
|
|---|---|
|
|
@@ -254,11 +234,11 @@ fixtures, autouse ones included, would change the service the cases see.
|
|
|
254
234
|
| `harness.replace(target, instance)` | a granted class or function of the service, replaced before the service boots |
|
|
255
235
|
| `harness.logs` | what the service logged, under its real logging configuration |
|
|
256
236
|
| `harness.clock` | the service's clock: the wall clock until a step calls `set()`, then `advance()` |
|
|
257
|
-
| `harness.<resource>.fail_with(error=None)` | the resource goes down for the service — see Postgres and Redis |
|
|
258
|
-
| `harness.<
|
|
259
|
-
| `harness.<
|
|
260
|
-
| `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
|
|
261
|
-
| `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 |
|
|
262
242
|
| `harness.application` | FastAPI: the app the case runs |
|
|
263
243
|
|
|
264
244
|
The service boots at the first step that reaches it, so a `Given` declares and
|
|
@@ -311,7 +291,7 @@ in `settings = "config.settings"` where FastAPI's names `app`; the rest is the s
|
|
|
311
291
|
| member | |
|
|
312
292
|
|---|---|
|
|
313
293
|
| a `Backend` | an HTTP neighbour: its origin, the variable that points the service at it, its OpenAPI document, the schema of its 5xx |
|
|
314
|
-
| 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 |
|
|
315
295
|
| `app` | FastAPI: the application, `module:attribute`; a function there builds a new one for every case |
|
|
316
296
|
| `settings` | Django: the settings module |
|
|
317
297
|
| `console` | FastAPI: the service's console, `module:callable`, where `pyproject.toml` has more than one script |
|
|
@@ -373,7 +353,11 @@ database = Postgres("DATABASE_URL", scheme="postgresql+psycopg")
|
|
|
373
353
|
The variable receives one URL with the scheme given, `postgresql` by default. The
|
|
374
354
|
server is `address=` if given, else `<SERVICE>_TEST_POSTGRES`, else a container
|
|
375
355
|
started through the Docker socket and kept between runs — starting postgres costs
|
|
376
|
-
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:
|
|
377
361
|
`shop_test_<pid>`, created at the first boot and dropped when the run ends. A run
|
|
378
362
|
killed before its end leaves its database behind, and the next run drops it: a
|
|
379
363
|
name of that shape, marked by specrig over an hour ago, with nobody connected.
|
|
@@ -399,7 +383,11 @@ resets.
|
|
|
399
383
|
`harness.database.fail_with()` closes the gate: every connection the service holds
|
|
400
384
|
is cut and every new one refused, as by a database that is down, and the service
|
|
401
385
|
meets its driver's own exception. An error given instead fails every statement
|
|
402
|
-
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
|
|
403
391
|
step sets before the boot waits for the service to start, so the migration and
|
|
404
392
|
the start go through; the next case starts whole.
|
|
405
393
|
|
|
@@ -420,6 +408,84 @@ refusals, before it writes the rows back. They live in a schema of their own,
|
|
|
420
408
|
`specrig_refusal`, which the reset's discovery and the `specrig_baseline` copy never
|
|
421
409
|
see.
|
|
422
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
|
+
|
|
423
489
|
## Redis
|
|
424
490
|
|
|
425
491
|
```python
|
|
@@ -553,7 +619,7 @@ the edits are let through, and the plugin says so once.
|
|
|
553
619
|
|
|
554
620
|
## Limits
|
|
555
621
|
|
|
556
|
-
- FastAPI 0.
|
|
622
|
+
- FastAPI 0.100 and up, and any ASGI application a module builds, through the
|
|
557
623
|
`fastapi` harness; Django 4.2 and up through its WSGI handler. Flask has no
|
|
558
624
|
harness.
|
|
559
625
|
- The service is imported once per run: what a module builds as it loads — a
|
|
@@ -575,7 +641,7 @@ the edits are let through, and the plugin says so once.
|
|
|
575
641
|
- The database is one URL in one variable. A Django service that sets its
|
|
576
642
|
`DATABASES` host by host from several variables is answered on the one the
|
|
577
643
|
harness names only.
|
|
578
|
-
- `Postgres` and `Redis` are the resources there are.
|
|
644
|
+
- `Postgres`, `MySQL`, `SQLite` and `Redis` are the resources there are.
|
|
579
645
|
- One service per run: two suites in one `pytest` process are refused, and a run
|
|
580
646
|
split by `pytest-xdist` writes no `tests/flows.yaml`, since no worker sees the
|
|
581
647
|
whole.
|
|
@@ -606,7 +672,7 @@ run against copies of the samples, then the suites of the two samples in
|
|
|
606
672
|
docker rm -f $(docker ps -aq --filter label=specrig=postgres)
|
|
607
673
|
```
|
|
608
674
|
|
|
609
|
-
removes the kept Postgres container.
|
|
675
|
+
removes the kept Postgres container, and `label=specrig=mysql` the MySQL one.
|
|
610
676
|
|
|
611
677
|
```bash
|
|
612
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 |
|
|
@@ -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 |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specrig"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "A harness for a FastAPI or Django service developed specification first"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -26,9 +26,11 @@ dependencies = [
|
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
[project.optional-dependencies]
|
|
29
|
-
fastapi = ["anyio>=4", "fastapi>=0.
|
|
29
|
+
fastapi = ["anyio>=4", "fastapi>=0.100"]
|
|
30
30
|
django = ["django>=4.2"]
|
|
31
|
-
postgres = ["psycopg[binary]>=3.1"]
|
|
31
|
+
postgres = ["psycopg[binary]>=3.1.8"]
|
|
32
|
+
mysql = ["pymysql[rsa]>=1.1"]
|
|
33
|
+
sqlite = []
|
|
32
34
|
|
|
33
35
|
[project.scripts]
|
|
34
36
|
specrig = "specrig.tool.cli:main"
|
|
@@ -42,15 +44,19 @@ Protocol = "https://github.com/specrig/protocol"
|
|
|
42
44
|
|
|
43
45
|
[dependency-groups]
|
|
44
46
|
dev = [
|
|
47
|
+
"aiomysql>=0.2",
|
|
48
|
+
"aiosqlite>=0.20",
|
|
45
49
|
"alembic>=1.16",
|
|
50
|
+
"asyncmy>=0.2.9",
|
|
46
51
|
"httpx>=0.27",
|
|
47
52
|
"httpx2>=2.0",
|
|
48
53
|
"psycopg[binary]>=3.2",
|
|
49
54
|
"pydantic-settings>=2.8",
|
|
55
|
+
"pymysql>=1.1",
|
|
50
56
|
"redis>=5.0",
|
|
51
57
|
"requests>=2.32",
|
|
52
58
|
"ruff>=0.12",
|
|
53
|
-
"sqlalchemy>=2.0",
|
|
59
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
54
60
|
]
|
|
55
61
|
|
|
56
62
|
[tool.hatch.build.targets.wheel]
|