specrig 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. {specrig-0.3.0 → specrig-0.3.1}/PKG-INFO +4 -2
  2. {specrig-0.3.0 → specrig-0.3.1}/README.md +3 -1
  3. {specrig-0.3.0 → specrig-0.3.1}/docs/design.md +2 -2
  4. {specrig-0.3.0 → specrig-0.3.1}/pyproject.toml +1 -1
  5. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/specrig.md +5 -2
  6. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/specrig.md +5 -2
  7. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/backend.py +25 -8
  8. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/tool/templates.py +4 -1
  9. {specrig-0.3.0 → specrig-0.3.1}/tests/test_backend.py +69 -0
  10. {specrig-0.3.0 → specrig-0.3.1}/.github/workflows/release.yml +0 -0
  11. {specrig-0.3.0 → specrig-0.3.1}/.gitignore +0 -0
  12. {specrig-0.3.0 → specrig-0.3.1}/LICENSE +0 -0
  13. {specrig-0.3.0 → specrig-0.3.1}/Makefile +0 -0
  14. {specrig-0.3.0 → specrig-0.3.1}/samples/docs/billing.yaml +0 -0
  15. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/AGENTS.md +0 -0
  16. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/CLAUDE.md +0 -0
  17. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/config/__init__.py +0 -0
  18. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/config/settings.py +0 -0
  19. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/config/urls.py +0 -0
  20. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/config/wsgi.py +0 -0
  21. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/manage.py +0 -0
  22. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/pyproject.toml +0 -0
  23. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/__init__.py +0 -0
  24. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/billing.py +0 -0
  25. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/jobs.py +0 -0
  26. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/management/__init__.py +0 -0
  27. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/management/commands/__init__.py +0 -0
  28. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/management/commands/remind.py +0 -0
  29. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/migrations/0001_initial.py +0 -0
  30. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/migrations/__init__.py +0 -0
  31. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/models.py +0 -0
  32. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/reminders/views.py +0 -0
  33. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/specrig.yaml +0 -0
  34. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/checks.yaml +0 -0
  35. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/features/history.feature +0 -0
  36. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/features/reminders.feature +0 -0
  37. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/flows.yaml +0 -0
  38. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/internal/boot.feature +0 -0
  39. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/internal/harness.py +0 -0
  40. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/internal/test_cases.py +0 -0
  41. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/steps/history.py +0 -0
  42. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/steps/reminders.py +0 -0
  43. {specrig-0.3.0 → specrig-0.3.1}/samples/reminders/tests/surface.yaml +0 -0
  44. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/AGENTS.md +0 -0
  45. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/CLAUDE.md +0 -0
  46. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/alembic.ini +0 -0
  47. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/migrations/env.py +0 -0
  48. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/migrations/versions/0001_orders.py +0 -0
  49. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/pyproject.toml +0 -0
  50. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/shop/__init__.py +0 -0
  51. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/shop/billing.py +0 -0
  52. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/shop/cli.py +0 -0
  53. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/shop/db.py +0 -0
  54. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/shop/jobs.py +0 -0
  55. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/shop/main.py +0 -0
  56. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/shop/settings.py +0 -0
  57. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/specrig.yaml +0 -0
  58. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/checks.yaml +0 -0
  59. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/features/orders.feature +0 -0
  60. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/features/placing.feature +0 -0
  61. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/flows.yaml +0 -0
  62. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/internal/boot.feature +0 -0
  63. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/internal/harness.py +0 -0
  64. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/internal/test_cases.py +0 -0
  65. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/steps/orders.py +0 -0
  66. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/steps/placing.py +0 -0
  67. {specrig-0.3.0 → specrig-0.3.1}/samples/shop/tests/surface.yaml +0 -0
  68. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/__init__.py +0 -0
  69. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/__main__.py +0 -0
  70. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/_yaml.py +0 -0
  71. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/asgi.py +0 -0
  72. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/clock.py +0 -0
  73. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/code/__init__.py +0 -0
  74. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/code/calls.py +0 -0
  75. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/code/django.py +0 -0
  76. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/code/fastapi.py +0 -0
  77. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/code/index.py +0 -0
  78. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/code/settings.py +0 -0
  79. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/contract.py +0 -0
  80. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/django/__init__.py +0 -0
  81. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/docker.py +0 -0
  82. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/fastapi/__init__.py +0 -0
  83. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/flows.py +0 -0
  84. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/gate.py +0 -0
  85. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/http.py +0 -0
  86. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/postgres.py +0 -0
  87. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/protocol/agents/contributing.md +0 -0
  88. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/protocol/agents/header.md +0 -0
  89. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/protocol/agents/reference.md +0 -0
  90. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/protocol/agents/rule11.md +0 -0
  91. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/protocol/agents/short.md +0 -0
  92. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/protocol/modes.yaml +0 -0
  93. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/proxy.py +0 -0
  94. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/py.typed +0 -0
  95. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/records.py +0 -0
  96. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/redis.py +0 -0
  97. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/resource.py +0 -0
  98. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/rig.py +0 -0
  99. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/run.py +0 -0
  100. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/standins.py +0 -0
  101. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/suite.py +0 -0
  102. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/surface.py +0 -0
  103. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/tool/__init__.py +0 -0
  104. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/tool/agents.py +0 -0
  105. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/tool/cli.py +0 -0
  106. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/tool/init.py +0 -0
  107. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/tool/modes.py +0 -0
  108. {specrig-0.3.0 → specrig-0.3.1}/src/specrig/tool/service.py +0 -0
  109. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/django/manage.py +0 -0
  110. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/django/pyproject.toml +0 -0
  111. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/django/stock/__init__.py +0 -0
  112. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/django/stock/api.py +0 -0
  113. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/django/stock/settings.py +0 -0
  114. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/django/stock/urls.py +0 -0
  115. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/django/stock/views.py +0 -0
  116. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/django/stock/warehouse.py +0 -0
  117. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/fastapi/.env.example +0 -0
  118. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/__init__.py +0 -0
  119. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/config.py +0 -0
  120. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/crm.py +0 -0
  121. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/main.py +0 -0
  122. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/routes/__init__.py +0 -0
  123. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/routes/orders.py +0 -0
  124. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/fastapi/gateway/sessions.py +0 -0
  125. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/fastapi/pyproject.toml +0 -0
  126. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/protocol/agents/verdicts.yaml +0 -0
  127. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/protocol/markers/areas.yaml +0 -0
  128. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/protocol/records/reminders.flows.yaml +0 -0
  129. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/protocol/records/reminders.trace.json +0 -0
  130. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/protocol/records/shop.flows.yaml +0 -0
  131. {specrig-0.3.0 → specrig-0.3.1}/tests/fixtures/protocol/records/shop.trace.json +0 -0
  132. {specrig-0.3.0 → specrig-0.3.1}/tests/test_agents.py +0 -0
  133. {specrig-0.3.0 → specrig-0.3.1}/tests/test_flows.py +0 -0
  134. {specrig-0.3.0 → specrig-0.3.1}/tests/test_format.py +0 -0
  135. {specrig-0.3.0 → specrig-0.3.1}/tests/test_gate.py +0 -0
  136. {specrig-0.3.0 → specrig-0.3.1}/tests/test_harness.py +0 -0
  137. {specrig-0.3.0 → specrig-0.3.1}/tests/test_init.py +0 -0
  138. {specrig-0.3.0 → specrig-0.3.1}/tests/test_postgres.py +0 -0
  139. {specrig-0.3.0 → specrig-0.3.1}/tests/test_rules.py +0 -0
  140. {specrig-0.3.0 → specrig-0.3.1}/tests/test_surface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: specrig
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A harness for a FastAPI or Django service developed specification first
5
5
  Project-URL: Homepage, https://github.com/specrig/specrig-python
6
6
  Project-URL: Protocol, https://github.com/specrig/protocol
@@ -243,7 +243,9 @@ fixtures, autouse ones included, would change the service the cases see.
243
243
  |---|---|
244
244
  | `harness.<backend>.reply(method, path, status, body, headers)` | what the neighbour answers; `headers`, strings to strings, are added to the response the client sees. A segment in braces, `/orders/{id}`, takes any one segment, the exact path wins over a template and the first declared of two templates over the second — in every call here that names a path |
245
245
  | `harness.<backend>.reply_to(method, path, query, status, body, headers)` | the same, for a request whose query has these values; the reply naming the most of them wins |
246
- | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once |
246
+ | `harness.<backend>.reply_with(method, path, answer, headers)` | the answer worked out from each request: `answer` takes what `requests` returns one of and gives `(status, body)`; a reply naming no query, checked as the others |
247
+ | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once; no reply lifts it |
248
+ | `harness.<backend>.heal()` | the neighbour answers again: every `time_out` is lifted, the replies stay |
247
249
  | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body; a template returns the requests it takes |
248
250
  | `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
249
251
  | `harness.run(job, *args, **kwargs)` | a job — see below |
@@ -215,7 +215,9 @@ fixtures, autouse ones included, would change the service the cases see.
215
215
  |---|---|
216
216
  | `harness.<backend>.reply(method, path, status, body, headers)` | what the neighbour answers; `headers`, strings to strings, are added to the response the client sees. A segment in braces, `/orders/{id}`, takes any one segment, the exact path wins over a template and the first declared of two templates over the second — in every call here that names a path |
217
217
  | `harness.<backend>.reply_to(method, path, query, status, body, headers)` | the same, for a request whose query has these values; the reply naming the most of them wins |
218
- | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once |
218
+ | `harness.<backend>.reply_with(method, path, answer, headers)` | the answer worked out from each request: `answer` takes what `requests` returns one of and gives `(status, body)`; a reply naming no query, checked as the others |
219
+ | `harness.<backend>.time_out(method, path, query=None)` | the neighbour does not answer in time: the client meets the timeout it would meet, at once; no reply lifts it |
220
+ | `harness.<backend>.heal()` | the neighbour answers again: every `time_out` is lifted, the replies stay |
219
221
  | `harness.<backend>.requests(method, path)` | what reached the neighbour: `method`, `path`, `query` and `json`, its decoded body; a template returns the requests it takes |
220
222
  | `harness.http()` | a client of the service's own stack: `get`, `post`, `put`, `patch`, `delete`, `send`; a response with `status`, `headers`, `text`, `json()` |
221
223
  | `harness.run(job, *args, **kwargs)` | a job — see below |
@@ -126,8 +126,8 @@ and `requests`' `HTTPAdapter`. Every other client meets the proxy.
126
126
 
127
127
  **A template names what a step answers, not what the service sent.** As in
128
128
  specrig-js, a path segment in braces takes any one segment, the exact path wins,
129
- and of two templates the first declared; `reply`, `reply_to`, `time_out` and
130
- `requests` all take one. The contract check and the trace go on naming a request
129
+ and of two templates the first declared; `reply`, `reply_to`, `reply_with`,
130
+ `time_out` and `requests` all take one. The contract check and the trace go on naming a request
131
131
  by the path it came with, so a template that matches too much is not hidden by
132
132
  the contract it is checked against. A reply's headers are added to the response
133
133
  as the transports hand it to the client, so `httpx`, `httpx2` and `requests` see
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "specrig"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "A harness for a FastAPI or Django service developed specification first"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,7 +1,7 @@
1
1
  # specrig.md
2
2
 
3
3
  How an agent works in this service under specrig: Rule #11, and how the suite runs.
4
- `../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.0, and an edit here is lost on the next
4
+ `../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.1, and an edit here is lost on the next
5
5
  run; `AGENTS.md` beside it is the service's own.
6
6
 
7
7
  **Read `CONTRIBUTING.md` first, if it exists.** It holds service-specific
@@ -400,7 +400,10 @@ neighbour `harness.py` names:
400
400
  every call below, and the exact path wins;
401
401
  - `harness.<neighbour>.reply_to(method, path, query, status, body)` — the same, for a
402
402
  request whose query has these values;
403
- - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
403
+ - `harness.<neighbour>.reply_with(method, path, answer)` — the answer worked out from
404
+ each request, `(status, body)`;
405
+ - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time,
406
+ until `harness.<neighbour>.heal()`;
404
407
  - `harness.<neighbour>.requests(method, path)` — what reached the neighbour, for a
405
408
  `Then`.
406
409
 
@@ -1,7 +1,7 @@
1
1
  # specrig.md
2
2
 
3
3
  How an agent works in this service under specrig: Rule #11, and how the suite runs.
4
- `../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.0, and an edit here is lost on the next
4
+ `../../.venv/bin/specrig agents` writes this file with `specrig` 0.3.1, and an edit here is lost on the next
5
5
  run; `AGENTS.md` beside it is the service's own.
6
6
 
7
7
  **Read `CONTRIBUTING.md` first, if it exists.** It holds service-specific
@@ -400,7 +400,10 @@ neighbour `harness.py` names:
400
400
  every call below, and the exact path wins;
401
401
  - `harness.<neighbour>.reply_to(method, path, query, status, body)` — the same, for a
402
402
  request whose query has these values;
403
- - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
403
+ - `harness.<neighbour>.reply_with(method, path, answer)` — the answer worked out from
404
+ each request, `(status, body)`;
405
+ - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time,
406
+ until `harness.<neighbour>.heal()`;
404
407
  - `harness.<neighbour>.requests(method, path)` — what reached the neighbour, for a
405
408
  `Then`.
406
409
 
@@ -27,6 +27,9 @@ class Received:
27
27
  json: Any
28
28
 
29
29
 
30
+ Answer = Callable[[Received], tuple[int, Any]]
31
+
32
+
30
33
  class StandIn(ABC):
31
34
  origin: str
32
35
  setting: str | None
@@ -55,7 +58,7 @@ class Backend(StandIn):
55
58
  self.name = ""
56
59
  self._trace = Trace()
57
60
  self._checked: Contract | None = None
58
- self._replies: dict[str, list[tuple[dict[str, list[str]], int, Any, dict[str, str]]]] = {}
61
+ self._replies: dict[str, list[tuple[dict[str, list[str]], Answer, dict[str, str]]]] = {}
59
62
  self._timeouts: dict[str, list[dict[str, list[str]]]] = {}
60
63
  self._received: list[Received] = []
61
64
  self._unanswered: list[str] = []
@@ -85,17 +88,28 @@ class Backend(StandIn):
85
88
 
86
89
  def reply_to(
87
90
  self, method: str, path: str, query: Query, status: int, body: Any = None, headers: Headers | None = None
91
+ ) -> None:
92
+ declared = copy.deepcopy(body)
93
+ self._declare(method, path, _listed(query), lambda _: (status, declared), headers)
94
+
95
+ def reply_with(self, method: str, path: str, answer: Answer, headers: Headers | None = None) -> None:
96
+ self._declare(method, path, {}, answer, headers)
97
+
98
+ def _declare(
99
+ self, method: str, path: str, asked: dict[str, list[str]], answer: Answer, headers: Headers | None
88
100
  ) -> None:
89
101
  key = f"{method.upper()} {path}"
90
- asked = _listed(query)
91
102
  kept = [r for r in self._replies.get(key, []) if r[0] != asked]
92
- self._replies[key] = [*kept, (asked, status, copy.deepcopy(body), dict(headers or {}))]
103
+ self._replies[key] = [*kept, (asked, answer, dict(headers or {}))]
93
104
 
94
105
  def time_out(self, method: str, path: str, query: Query | None = None) -> None:
95
106
  key = f"{method.upper()} {path}"
96
107
  asked = _listed(query or {})
97
108
  self._timeouts[key] = [*(q for q in self._timeouts.get(key, []) if q != asked), asked]
98
109
 
110
+ def heal(self) -> None:
111
+ self._timeouts = {}
112
+
99
113
  def requests(self, method: str, path: str) -> list[Received]:
100
114
  return [r for r in self._received if r.method == method.upper() and _takes(path, r.path)]
101
115
 
@@ -107,22 +121,25 @@ class Backend(StandIn):
107
121
  body = http.decoded(request.body)
108
122
  call = self._trace.begin()
109
123
  try:
110
- self._received.append(Received(method, path, asked, body))
124
+ received = Received(method, path, asked, body)
125
+ self._received.append(received)
111
126
  if self._checked is not None:
112
127
  self._violations.extend(self._checked.request(method, path, body))
113
128
  if _pick(_declared(self._timeouts, method, path), asked, lambda q: q) is not None:
114
129
  raise Unreachable(f"{self.name} did not answer {key} in time", timed_out=True)
115
- return self._fulfil(method, path, key + request.search(), asked)
130
+ return self._fulfil(received, key + request.search())
116
131
  finally:
117
132
  name = self._checked.name(method, path) if self._checked is not None else key
118
133
  self._trace.end(call, "out", "http", name, body)
119
134
 
120
- def _fulfil(self, method: str, path: str, asked: str, query: dict[str, list[str]]) -> Response:
121
- reply = _pick(_declared(self._replies, method, path), query, lambda r: r[0])
135
+ def _fulfil(self, received: Received, asked: str) -> Response:
136
+ method, path = received.method, received.path
137
+ reply = _pick(_declared(self._replies, method, path), received.query, lambda r: r[0])
122
138
  if reply is None:
123
139
  self._unanswered.append(asked)
124
140
  return _respond(501, {"code": "unanswered", "message": f"{method} {path}"})
125
- _, status, body, headers = reply
141
+ _, answer, headers = reply
142
+ status, body = answer(received)
126
143
  if self._checked is not None:
127
144
  self._violations.extend(self._checked.response(method, path, status, {} if body is None else body))
128
145
  return _respond(status, None if status == 204 else ({} if body is None else body), headers)
@@ -166,7 +166,10 @@ neighbour `harness.py` names:
166
166
  every call below, and the exact path wins;
167
167
  - `harness.<neighbour>.reply_to(method, path, query, status, body)` — the same, for a
168
168
  request whose query has these values;
169
- - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time;
169
+ - `harness.<neighbour>.reply_with(method, path, answer)` — the answer worked out from
170
+ each request, `(status, body)`;
171
+ - `harness.<neighbour>.time_out(method, path)` — the neighbour not answering in time,
172
+ until `harness.<neighbour>.heal()`;
170
173
  - `harness.<neighbour>.requests(method, path)` — what reached the neighbour, for a
171
174
  `Then`.
172
175
 
@@ -178,6 +178,75 @@ def test_a_neighbour_that_does_not_answer_in_time_is_unreachable_by_template_too
178
178
  assert get(backend, "/payments") == 200
179
179
 
180
180
 
181
+ def test_heal_lifts_every_time_out_and_keeps_the_replies() -> None:
182
+ backend, _ = billing()
183
+ backend.reply("GET", "/invoices", 200, [])
184
+ backend.time_out("GET", "/invoices/7")
185
+ backend.time_out("GET", "/invoices", {"status": "paid"})
186
+ backend.reply("GET", "/invoices/7", 200, INVOICE)
187
+
188
+ with pytest.raises(Unreachable):
189
+ get(backend, "/invoices/7")
190
+ backend.heal()
191
+
192
+ assert get(backend, "/invoices/7") == 200
193
+ assert get(backend, "/invoices?status=paid") == 200
194
+
195
+
196
+ def test_an_answer_is_worked_out_from_each_request() -> None:
197
+ backend, _ = billing()
198
+ backend.reply_with(
199
+ "GET",
200
+ "/invoices/{order}",
201
+ lambda r: (200, {**INVOICE, "order": int(r.path.rsplit("/", 1)[1])}),
202
+ {"X-Kind": "own"},
203
+ )
204
+
205
+ for order in (7, 8):
206
+ answered = backend.answer(Request("GET", f"https://billing.shop.test/invoices/{order}"))
207
+ assert (answered.status, answered.json()["order"], answered.headers["X-Kind"]) == (200, order, "own")
208
+ assert backend.failures() == []
209
+
210
+
211
+ def test_an_answer_may_change_with_what_reached_the_neighbour() -> None:
212
+ backend, _ = billing()
213
+ backend.reply_with(
214
+ "GET",
215
+ "/invoices/7",
216
+ lambda r: (
217
+ (503, {"code": "busy", "message": "later"})
218
+ if len(backend.requests("GET", "/invoices/7")) == 1
219
+ else (200, INVOICE)
220
+ ),
221
+ )
222
+
223
+ assert [get(backend, "/invoices/7"), get(backend, "/invoices/7")] == [503, 200]
224
+ assert backend.failures() == []
225
+
226
+
227
+ def test_an_answer_worked_out_is_a_declaration_naming_no_query() -> None:
228
+ backend, _ = billing()
229
+ backend.reply_with("GET", "/invoices", lambda r: (200, [INVOICE]))
230
+ backend.reply_to("GET", "/invoices", {"status": "paid"}, 200, [])
231
+
232
+ def listed(path: str) -> list[object]:
233
+ return backend.answer(Request("GET", f"https://billing.shop.test{path}")).json()
234
+
235
+ assert (listed("/invoices?status=unpaid"), listed("/invoices?status=paid")) == ([INVOICE], [])
236
+ backend.reply("GET", "/invoices", 200, [])
237
+ assert listed("/invoices") == []
238
+
239
+
240
+ def test_the_contract_checks_an_answer_worked_out() -> None:
241
+ backend, _ = billing()
242
+ backend.reply_with("GET", "/invoices/{order}", lambda r: (200, {"order": 7}))
243
+
244
+ get(backend, "/invoices/7")
245
+
246
+ [failure] = backend.failures()
247
+ assert "GET /invoices/7 answered 200: $ 'amount' is a required property" in failure
248
+
249
+
181
250
  def test_the_contract_check_and_the_trace_name_a_request_by_its_real_path() -> None:
182
251
  backend, trace = billing()
183
252
  backend.reply("GET", "/invoices/{order}", 200, {"order": 7})
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes