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.
Files changed (149) hide show
  1. {specrig-0.3.1 → specrig-0.3.2}/Makefile +1 -1
  2. specrig-0.3.1/README.md → specrig-0.3.2/PKG-INFO +144 -19
  3. specrig-0.3.1/PKG-INFO → specrig-0.3.2/README.md +113 -47
  4. {specrig-0.3.1 → specrig-0.3.2}/docs/design.md +56 -5
  5. {specrig-0.3.1 → specrig-0.3.2}/pyproject.toml +10 -4
  6. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/specrig.md +4 -4
  7. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/specrig.md +4 -4
  8. specrig-0.3.2/src/specrig/__init__.py +10 -0
  9. specrig-0.3.2/src/specrig/database.py +165 -0
  10. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/django/__init__.py +2 -1
  11. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/docker.py +5 -1
  12. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/fastapi/__init__.py +13 -5
  13. specrig-0.3.2/src/specrig/mysql.py +318 -0
  14. specrig-0.3.2/src/specrig/postgres.py +193 -0
  15. specrig-0.3.2/src/specrig/server.py +194 -0
  16. specrig-0.3.2/src/specrig/sqlite.py +194 -0
  17. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/init.py +64 -11
  18. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/templates.py +3 -3
  19. specrig-0.3.2/tests/test_docker.py +39 -0
  20. {specrig-0.3.1 → specrig-0.3.2}/tests/test_harness.py +191 -1
  21. {specrig-0.3.1 → specrig-0.3.2}/tests/test_init.py +116 -0
  22. specrig-0.3.2/tests/test_mysql.py +612 -0
  23. {specrig-0.3.1 → specrig-0.3.2}/tests/test_postgres.py +41 -0
  24. specrig-0.3.2/tests/test_sqlite.py +426 -0
  25. specrig-0.3.1/src/specrig/__init__.py +0 -7
  26. specrig-0.3.1/src/specrig/postgres.py +0 -435
  27. {specrig-0.3.1 → specrig-0.3.2}/.github/workflows/release.yml +0 -0
  28. {specrig-0.3.1 → specrig-0.3.2}/.gitignore +0 -0
  29. {specrig-0.3.1 → specrig-0.3.2}/LICENSE +0 -0
  30. {specrig-0.3.1 → specrig-0.3.2}/samples/docs/billing.yaml +0 -0
  31. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/AGENTS.md +0 -0
  32. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/CLAUDE.md +0 -0
  33. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/config/__init__.py +0 -0
  34. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/config/settings.py +0 -0
  35. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/config/urls.py +0 -0
  36. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/config/wsgi.py +0 -0
  37. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/manage.py +0 -0
  38. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/pyproject.toml +0 -0
  39. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/__init__.py +0 -0
  40. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/billing.py +0 -0
  41. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/jobs.py +0 -0
  42. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/management/__init__.py +0 -0
  43. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/management/commands/__init__.py +0 -0
  44. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/management/commands/remind.py +0 -0
  45. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/migrations/0001_initial.py +0 -0
  46. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/migrations/__init__.py +0 -0
  47. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/models.py +0 -0
  48. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/reminders/views.py +0 -0
  49. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/specrig.yaml +0 -0
  50. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/checks.yaml +0 -0
  51. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/features/history.feature +0 -0
  52. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/features/reminders.feature +0 -0
  53. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/flows.yaml +0 -0
  54. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/internal/boot.feature +0 -0
  55. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/internal/harness.py +0 -0
  56. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/internal/test_cases.py +0 -0
  57. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/steps/history.py +0 -0
  58. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/steps/reminders.py +0 -0
  59. {specrig-0.3.1 → specrig-0.3.2}/samples/reminders/tests/surface.yaml +0 -0
  60. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/AGENTS.md +0 -0
  61. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/CLAUDE.md +0 -0
  62. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/alembic.ini +0 -0
  63. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/migrations/env.py +0 -0
  64. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/migrations/versions/0001_orders.py +0 -0
  65. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/pyproject.toml +0 -0
  66. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/__init__.py +0 -0
  67. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/billing.py +0 -0
  68. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/cli.py +0 -0
  69. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/db.py +0 -0
  70. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/jobs.py +0 -0
  71. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/main.py +0 -0
  72. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/shop/settings.py +0 -0
  73. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/specrig.yaml +0 -0
  74. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/checks.yaml +0 -0
  75. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/features/orders.feature +0 -0
  76. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/features/placing.feature +0 -0
  77. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/flows.yaml +0 -0
  78. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/internal/boot.feature +0 -0
  79. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/internal/harness.py +0 -0
  80. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/internal/test_cases.py +0 -0
  81. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/steps/orders.py +0 -0
  82. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/steps/placing.py +0 -0
  83. {specrig-0.3.1 → specrig-0.3.2}/samples/shop/tests/surface.yaml +0 -0
  84. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/__main__.py +0 -0
  85. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/_yaml.py +0 -0
  86. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/asgi.py +0 -0
  87. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/backend.py +0 -0
  88. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/clock.py +0 -0
  89. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/__init__.py +0 -0
  90. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/calls.py +0 -0
  91. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/django.py +0 -0
  92. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/fastapi.py +0 -0
  93. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/index.py +0 -0
  94. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/code/settings.py +0 -0
  95. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/contract.py +0 -0
  96. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/flows.py +0 -0
  97. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/gate.py +0 -0
  98. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/http.py +0 -0
  99. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/contributing.md +0 -0
  100. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/header.md +0 -0
  101. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/reference.md +0 -0
  102. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/rule11.md +0 -0
  103. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/agents/short.md +0 -0
  104. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/protocol/modes.yaml +0 -0
  105. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/proxy.py +0 -0
  106. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/py.typed +0 -0
  107. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/records.py +0 -0
  108. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/redis.py +0 -0
  109. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/resource.py +0 -0
  110. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/rig.py +0 -0
  111. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/run.py +0 -0
  112. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/standins.py +0 -0
  113. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/suite.py +0 -0
  114. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/surface.py +0 -0
  115. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/__init__.py +0 -0
  116. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/agents.py +0 -0
  117. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/cli.py +0 -0
  118. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/modes.py +0 -0
  119. {specrig-0.3.1 → specrig-0.3.2}/src/specrig/tool/service.py +0 -0
  120. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/manage.py +0 -0
  121. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/pyproject.toml +0 -0
  122. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/__init__.py +0 -0
  123. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/api.py +0 -0
  124. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/settings.py +0 -0
  125. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/urls.py +0 -0
  126. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/views.py +0 -0
  127. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/django/stock/warehouse.py +0 -0
  128. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/.env.example +0 -0
  129. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/__init__.py +0 -0
  130. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/config.py +0 -0
  131. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/crm.py +0 -0
  132. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/main.py +0 -0
  133. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/routes/__init__.py +0 -0
  134. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/routes/orders.py +0 -0
  135. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/gateway/sessions.py +0 -0
  136. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/fastapi/pyproject.toml +0 -0
  137. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/agents/verdicts.yaml +0 -0
  138. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/markers/areas.yaml +0 -0
  139. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/records/reminders.flows.yaml +0 -0
  140. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/records/reminders.trace.json +0 -0
  141. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/records/shop.flows.yaml +0 -0
  142. {specrig-0.3.1 → specrig-0.3.2}/tests/fixtures/protocol/records/shop.trace.json +0 -0
  143. {specrig-0.3.1 → specrig-0.3.2}/tests/test_agents.py +0 -0
  144. {specrig-0.3.1 → specrig-0.3.2}/tests/test_backend.py +0 -0
  145. {specrig-0.3.1 → specrig-0.3.2}/tests/test_flows.py +0 -0
  146. {specrig-0.3.1 → specrig-0.3.2}/tests/test_format.py +0 -0
  147. {specrig-0.3.1 → specrig-0.3.2}/tests/test_gate.py +0 -0
  148. {specrig-0.3.1 → specrig-0.3.2}/tests/test_rules.py +0 -0
  149. {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]`, then:
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. Postgres becomes a `Postgres` resource where the
82
- service depends on a Postgres driver and reads `DATABASE_URL`, with the URL's
83
- scheme from `.env.example` or the drivers — `postgresql+psycopg`,
84
- `postgresql+asyncpg`; Redis a `Redis` where it depends on `redis` and reads a
85
- variable that names it. Every other variable the code needs is a constant the
86
- harness answers empty, and a Django service gets a `SECRET_KEY` of its own.
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.<postgres>.refuse(table, *writes)` | every insert, update and delete on the table, or those given, fails in Postgres itself, whoever sends it; reads go on working — see Postgres |
231
- | `harness.<postgres>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
232
- | `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
233
- | `harness.<postgres>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
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. The database is the run's own:
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. A fault a
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.90 and up, and any ASGI application a module builds, through the
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]`, then:
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. Postgres becomes a `Postgres` resource where the
110
- service depends on a Postgres driver and reads `DATABASE_URL`, with the URL's
111
- scheme from `.env.example` or the drivers — `postgresql+psycopg`,
112
- `postgresql+asyncpg`; Redis a `Redis` where it depends on `redis` and reads a
113
- variable that names it. Every other variable the code needs is a constant the
114
- harness answers empty, and a Django service gets a `SECRET_KEY` of its own.
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.<postgres>.refuse(table, *writes)` | every insert, update and delete on the table, or those given, fails in Postgres itself, whoever sends it; reads go on working — see Postgres |
259
- | `harness.<postgres>.refuse_after(table, rows, *writes)` | the same after the first `rows` rows |
260
- | `harness.<postgres>.refuse_commit(table, *writes)` | the statements go through, and the commit of a transaction that wrote the table fails |
261
- | `harness.<postgres>.heal()` | the database comes back: `fail_with` and every refusal end, and the rows stay |
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. The database is the run's own:
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. A fault a
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.90 and up, and any ASGI application a module builds, through the
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 need none.
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.90 and up, and any
174
- ASGI app a module builds; Django 4.2 and up. Not the newest, and not what nobody
175
- runs any more.
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.1"
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.90"]
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]