patchground 0.1.0__py3-none-any.whl

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 (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,34 @@
1
+ import pytest
2
+
3
+ from stockimport import ImportFailed, ImportResult, Item, RowError, import_inventory
4
+
5
+ HEADER = "sku,quantity,unit_price"
6
+
7
+
8
+ def doc(*lines):
9
+ return "\n".join((HEADER, *lines)) + "\n"
10
+
11
+
12
+ def test_strict_returns_items_in_file_order():
13
+ items = import_inventory(doc("A-1,5,19.99", "B-1,2,0.29"))
14
+ assert items == [Item("A-1", 5, 1999), Item("B-1", 2, 29)]
15
+
16
+
17
+ def test_strict_default_raises_the_first_error():
18
+ with pytest.raises(ImportFailed) as caught:
19
+ import_inventory(doc("A-1,5,19.99", "A-2,1,x", "A-3,y,1.00"))
20
+ assert (caught.value.error.line, caught.value.error.code) == (3, "invalid_price")
21
+ assert isinstance(caught.value, ValueError)
22
+
23
+
24
+ def test_result_types_have_the_documented_fields():
25
+ error = RowError(2, "sku", "invalid_sku", "bad")
26
+ result = ImportResult([Item("A", 1, 100)], [error])
27
+ assert result.items == [Item("A", 1, 100)]
28
+ assert (error.line, error.column, error.code) == (2, "sku", "invalid_sku")
29
+
30
+
31
+ def test_non_strict_returns_valid_items_and_row_errors():
32
+ result = import_inventory(doc("A-1,5,19.99", "A-2,x,1.00", "B-1,2,0.29"), strict=False)
33
+ assert result.items == [Item("A-1", 5, 1999), Item("B-1", 2, 29)]
34
+ assert [(e.line, e.column, e.code) for e in result.errors] == [(3, "quantity", "invalid_quantity")]
@@ -0,0 +1,5 @@
1
+ - Which rules in the ticket did you decide were already satisfied by the strict code, and which did you have to change? How did you check?
2
+ - Where does an invalid row reserve its sku in the strict code, and why did that not matter before non-strict mode existed?
3
+ - How did you decide what non-strict mode does with a malformed CSV, and what did you leave unchanged?
4
+ - Which of your tests would fail if line numbers counted records instead of physical lines?
5
+ - If an AI assistant wrote part of the change, what did you verify yourself before accepting it?
@@ -0,0 +1,3 @@
1
+ FROM python:3.12-slim
2
+ ENV PYTHONDONTWRITEBYTECODE=1
3
+ RUN pip install --no-cache-dir pytest==8.3.5
@@ -0,0 +1,74 @@
1
+ diff --git a/src/stockimport/importer.py b/src/stockimport/importer.py
2
+ index 60e061c..3698620 100644
3
+ --- a/src/stockimport/importer.py
4
+ +++ b/src/stockimport/importer.py
5
+ @@ -6,8 +6,9 @@ import csv
6
+ import io
7
+ import re
8
+ from collections.abc import Iterator
9
+ +from typing import Literal, overload
10
+
11
+ -from .models import ImportFailed, Item, RowError
12
+ +from .models import ImportFailed, ImportResult, Item, RowError
13
+
14
+ HEADER = ["sku", "quantity", "unit_price"]
15
+
16
+ @@ -20,25 +21,40 @@ def _fail(line: int, column: str | None, code: str, message: str) -> ImportFaile
17
+ return ImportFailed(RowError(line, column, code, message))
18
+
19
+
20
+ -def import_inventory(text: str) -> list[Item]:
21
+ +@overload
22
+ +def import_inventory(text: str) -> list[Item]: ...
23
+ +@overload
24
+ +def import_inventory(text: str, *, strict: Literal[True]) -> list[Item]: ...
25
+ +@overload
26
+ +def import_inventory(text: str, *, strict: Literal[False]) -> ImportResult: ...
27
+ +def import_inventory(text: str, *, strict: bool = True) -> list[Item] | ImportResult:
28
+ """Parse ``text`` and return its items in file order.
29
+
30
+ - The first problem found stops the import with ``ImportFailed``.
31
+ + Strict (default): the first row problem stops the import with ``ImportFailed``.
32
+ + ``strict=False``: row problems are collected and returned with the accepted items.
33
+ + Header and CSV syntax problems raise ``ImportFailed`` in both modes.
34
+ """
35
+ reader = csv.reader(io.StringIO(text, newline=""), strict=True)
36
+ _read_header(reader)
37
+ items: list[Item] = []
38
+ + errors: list[RowError] = []
39
+ seen: set[str] = set()
40
+ while True:
41
+ line = reader.line_num + 1 # physical line where the next record starts
42
+ try:
43
+ record = next(reader)
44
+ except StopIteration:
45
+ - return items
46
+ + return items if strict else ImportResult(items, errors)
47
+ except csv.Error as exc:
48
+ raise _fail(line, None, "malformed_csv", str(exc)) from None
49
+ - if record: # a physical line with no characters yields []
50
+ + if not record: # a physical line with no characters yields []
51
+ + continue
52
+ + try:
53
+ items.append(_parse_record(record, line, seen))
54
+ + except ImportFailed as exc:
55
+ + if strict:
56
+ + raise
57
+ + errors.append(exc.error)
58
+
59
+
60
+ def _read_header(reader: Iterator[list[str]]) -> None:
61
+ @@ -58,7 +74,6 @@ def _parse_record(record: list[str], line: int, seen: set[str]) -> Item:
62
+ raise _fail(line, "sku", "invalid_sku", f"invalid sku {sku!r}")
63
+ if sku in seen:
64
+ raise _fail(line, "sku", "duplicate_sku", f"duplicate sku {sku!r}")
65
+ - seen.add(sku)
66
+ if not _QUANTITY.fullmatch(quantity):
67
+ raise _fail(line, "quantity", "invalid_quantity", f"invalid quantity {quantity!r}")
68
+ match = _PRICE.fullmatch(price)
69
+ @@ -66,4 +81,5 @@ def _parse_record(record: list[str], line: int, seen: set[str]) -> Item:
70
+ raise _fail(line, "unit_price", "invalid_price", f"invalid unit_price {price!r}")
71
+ dollars, fraction = match.groups()
72
+ cents = int(dollars) * 100 + int((fraction or "").ljust(2, "0"))
73
+ + seen.add(sku) # only fully valid rows reserve their sku
74
+ return Item(sku, int(quantity), cents)
@@ -0,0 +1,8 @@
1
+ [project]
2
+ name = "stockimport"
3
+ version = "0.3.0"
4
+ requires-python = ">=3.12"
5
+
6
+ [tool.pytest.ini_options]
7
+ pythonpath = ["src"]
8
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ from .importer import import_inventory
2
+ from .models import ImportFailed, ImportResult, Item, RowError
3
+
4
+ __all__ = ["ImportFailed", "ImportResult", "Item", "RowError", "import_inventory"]
@@ -0,0 +1,69 @@
1
+ """Strict CSV import for the warehouse stock feed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import re
8
+ from collections.abc import Iterator
9
+
10
+ from .models import ImportFailed, Item, RowError
11
+
12
+ HEADER = ["sku", "quantity", "unit_price"]
13
+
14
+ _SKU = re.compile(r"[A-Z0-9-]+")
15
+ _QUANTITY = re.compile(r"[0-9]+")
16
+ _PRICE = re.compile(r"([0-9]+)(?:\.([0-9]{1,2}))?")
17
+
18
+
19
+ def _fail(line: int, column: str | None, code: str, message: str) -> ImportFailed:
20
+ return ImportFailed(RowError(line, column, code, message))
21
+
22
+
23
+ def import_inventory(text: str) -> list[Item]:
24
+ """Parse ``text`` and return its items in file order.
25
+
26
+ The first problem found stops the import with ``ImportFailed``.
27
+ """
28
+ reader = csv.reader(io.StringIO(text, newline=""), strict=True)
29
+ _read_header(reader)
30
+ items: list[Item] = []
31
+ seen: set[str] = set()
32
+ while True:
33
+ line = reader.line_num + 1 # physical line where the next record starts
34
+ try:
35
+ record = next(reader)
36
+ except StopIteration:
37
+ return items
38
+ except csv.Error as exc:
39
+ raise _fail(line, None, "malformed_csv", str(exc)) from None
40
+ if record: # a physical line with no characters yields []
41
+ items.append(_parse_record(record, line, seen))
42
+
43
+
44
+ def _read_header(reader: Iterator[list[str]]) -> None:
45
+ try:
46
+ header = next(reader)
47
+ except (StopIteration, csv.Error):
48
+ header = None
49
+ if header != HEADER:
50
+ raise _fail(1, None, "bad_header", "header must be exactly sku,quantity,unit_price")
51
+
52
+
53
+ def _parse_record(record: list[str], line: int, seen: set[str]) -> Item:
54
+ if len(record) != 3:
55
+ raise _fail(line, None, "wrong_field_count", f"expected 3 fields, got {len(record)}")
56
+ sku, quantity, price = record
57
+ if not _SKU.fullmatch(sku):
58
+ raise _fail(line, "sku", "invalid_sku", f"invalid sku {sku!r}")
59
+ if sku in seen:
60
+ raise _fail(line, "sku", "duplicate_sku", f"duplicate sku {sku!r}")
61
+ seen.add(sku)
62
+ if not _QUANTITY.fullmatch(quantity):
63
+ raise _fail(line, "quantity", "invalid_quantity", f"invalid quantity {quantity!r}")
64
+ match = _PRICE.fullmatch(price)
65
+ if match is None:
66
+ raise _fail(line, "unit_price", "invalid_price", f"invalid unit_price {price!r}")
67
+ dollars, fraction = match.groups()
68
+ cents = int(dollars) * 100 + int((fraction or "").ljust(2, "0"))
69
+ return Item(sku, int(quantity), cents)
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Item:
8
+ sku: str
9
+ quantity: int
10
+ unit_price_cents: int
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RowError:
15
+ line: int
16
+ column: str | None
17
+ code: str
18
+ message: str
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ImportResult:
23
+ items: list[Item]
24
+ errors: list[RowError]
25
+
26
+
27
+ class ImportFailed(ValueError):
28
+ """Raised when an import stops; ``error`` says why."""
29
+
30
+ def __init__(self, error: RowError) -> None:
31
+ super().__init__(f"line {error.line}: {error.message}")
32
+ self.error = error
@@ -0,0 +1,28 @@
1
+ # Duplicate charges after a payment retry
2
+
3
+ Support reproduced a checkout charging twice when the provider accepted a payment
4
+ but its response timed out. The next transport attempt returned success, so the
5
+ customer saw one successful checkout and two ledger entries.
6
+
7
+ Fix the payment flow and add a regression check. The public entry point is
8
+ `PaymentService(gateway, max_attempts=3).pay(request_id, amount_cents)` in
9
+ `src/payments/service.py`. Use the offline gateway in `src/payments/gateway.py`;
10
+ its scripted events reproduce outages without network access or sleeps.
11
+
12
+ Required behavior:
13
+ - One logical request must create at most one charge, including after a lost
14
+ success response and when submitted again through a new service instance
15
+ sharing the same gateway. Successful repeats return the same receipt.
16
+ - Different request IDs represent different payments even for equal amounts.
17
+ Reusing an ID with a different amount raises `RequestConflict`.
18
+ - Retry only `GatewayTimeout`, at most `max_attempts` total calls per invocation.
19
+ If all attempts time out, expose the final timeout. Propagate `PaymentDeclined`
20
+ and invalid input immediately. Amounts are positive integer cents.
21
+ - Preserve the `Receipt(charge_id, amount_cents)` API and gateway semantics.
22
+
23
+ The simulator persists accepted requests only for the lifetime of its gateway
24
+ instance. Cross-process persistence, concurrent workers, real providers, backoff,
25
+ credentials and payment security are outside this exercise. Work in application
26
+ code and conventional pytest checks; keep verification configuration intact.
27
+
28
+ Run checks using Patchground's container commands. No real payments are made.
@@ -0,0 +1,30 @@
1
+ spec_version: "0.1"
2
+ id: duplicate-charges-on-retry
3
+ version: 1.0.1
4
+ title: Duplicate charges after a payment retry
5
+ type: debug
6
+ summary: A successful charge followed by a timeout causes a retry to charge twice.
7
+ languages: [python]
8
+ tags: [idempotency, retries, payments]
9
+ difficulty: intermediate
10
+ timebox_minutes: 45
11
+ ai_policy: allowed
12
+ environment:
13
+ dockerfile: environment/Dockerfile
14
+ services: []
15
+ test_command: pytest -q -p no:cacheprovider --junitxml=$PG_RESULTS/junit.xml
16
+ checks:
17
+ visible: checks/visible
18
+ hidden: checks/hidden
19
+ solution: solution/reference.patch
20
+ expected_paths: [src/payments/, tests/]
21
+ hints:
22
+ - A timeout says nothing about whether the provider committed the charge.
23
+ - Compare the identity of a logical payment with the identity of a transport attempt.
24
+ debrief:
25
+ questions: debrief/questions.md
26
+ focus: [debugging_method, test_quality, scope_judgment]
27
+ author: {name: Patchground contributors}
28
+ content_license: CC-BY-4.0
29
+ provenance: generated
30
+ generated_by: {model: "Codex configured default (model unverified)", prompt_version: launch-payment-v1}
@@ -0,0 +1,87 @@
1
+ import pytest
2
+ from payments.gateway import OfflineGateway
3
+ from payments.models import GatewayTimeout, RequestConflict
4
+ from payments.service import PaymentService
5
+
6
+
7
+ def test_ambiguous_acceptance_has_one_ledger_entry():
8
+ gateway = OfflineGateway(("timeout_before", "timeout_after", "ok"))
9
+ receipt = PaymentService(gateway).pay("invoice-71", 8317)
10
+ assert len(gateway.charges) == 1
11
+ assert list(gateway.charges.values()) == [receipt]
12
+ assert len(gateway.calls) == 3
13
+
14
+
15
+ def test_replay_from_another_service_returns_original_receipt():
16
+ gateway = OfflineGateway(("timeout_after", "ok"))
17
+ first = PaymentService(gateway).pay("invoice-72", 1800)
18
+ second = PaymentService(gateway).pay("invoice-72", 1800)
19
+ assert first == second
20
+ assert len(gateway.charges) == 1
21
+
22
+
23
+ def test_equal_amounts_with_different_ids_are_distinct():
24
+ gateway = OfflineGateway()
25
+ service = PaymentService(gateway)
26
+ first = service.pay("invoice-73", 999)
27
+ second = service.pay("invoice-74", 999)
28
+ assert first.charge_id != second.charge_id
29
+ assert len(gateway.charges) == 2
30
+
31
+
32
+ def test_changed_amount_after_ambiguous_acceptance_is_rejected():
33
+ gateway = OfflineGateway(("timeout_after", "ok"))
34
+ service = PaymentService(gateway)
35
+ service.pay("invoice-75", 4100)
36
+ before = len(gateway.calls)
37
+ with pytest.raises(RequestConflict):
38
+ service.pay("invoice-75", 4200)
39
+ assert len(gateway.calls) == before + 1
40
+ assert len(gateway.charges) == 1
41
+
42
+
43
+ def test_later_invocation_recovers_accepted_charge_after_exhaustion():
44
+ gateway = OfflineGateway(("timeout_after",))
45
+ with pytest.raises(GatewayTimeout):
46
+ PaymentService(gateway, max_attempts=1).pay("invoice-76", 300)
47
+ receipt = PaymentService(gateway).pay("invoice-76", 300)
48
+ assert list(gateway.charges.values()) == [receipt]
49
+ assert len(gateway.calls) == 2
50
+
51
+
52
+ def test_before_acceptance_retry_recovers():
53
+ gateway = OfflineGateway(("timeout_before", "ok"))
54
+ receipt = PaymentService(gateway).pay("invoice-77", 700)
55
+ assert list(gateway.charges.values()) == [receipt]
56
+ assert len(gateway.calls) == 2
57
+
58
+
59
+ def test_final_timeout_is_exposed_without_extra_attempts():
60
+ gateway = OfflineGateway(("timeout_before",) * 5)
61
+ with pytest.raises(GatewayTimeout, match="before acceptance"):
62
+ PaymentService(gateway, max_attempts=3).pay("invoice-78", 2200)
63
+ assert len(gateway.calls) == 3
64
+ assert not gateway.charges
65
+
66
+
67
+ @pytest.mark.parametrize("amount", [0, -1, True, 1.5, "100"])
68
+ def test_invalid_amount_does_not_create_charge(amount):
69
+ gateway = OfflineGateway()
70
+ with pytest.raises(ValueError):
71
+ PaymentService(gateway).pay("invoice-79", amount)
72
+ assert not gateway.charges
73
+ assert not gateway.calls
74
+
75
+
76
+ @pytest.mark.parametrize("request_id", ["", " ", None])
77
+ def test_invalid_request_identity_does_not_reach_provider(request_id):
78
+ gateway = OfflineGateway()
79
+ with pytest.raises(ValueError):
80
+ PaymentService(gateway).pay(request_id, 100)
81
+ assert not gateway.calls
82
+
83
+
84
+ @pytest.mark.parametrize("limit", [0, -2, True, 1.5])
85
+ def test_invalid_retry_limit_is_rejected(limit):
86
+ with pytest.raises(ValueError):
87
+ PaymentService(OfflineGateway(), max_attempts=limit)
@@ -0,0 +1,34 @@
1
+ import pytest
2
+ from payments.gateway import OfflineGateway
3
+ from payments.models import GatewayTimeout, PaymentDeclined
4
+ from payments.service import PaymentService
5
+
6
+
7
+ def test_successful_checkout():
8
+ gateway = OfflineGateway()
9
+ receipt = PaymentService(gateway).pay("order-a", 1250)
10
+ assert receipt.amount_cents == 1250
11
+ assert len(gateway.charges) == 1
12
+
13
+
14
+ def test_lost_success_response_does_not_charge_twice():
15
+ gateway = OfflineGateway(("timeout_after", "ok"))
16
+ receipt = PaymentService(gateway).pay("order-b", 2500)
17
+ assert receipt.amount_cents == 2500
18
+ assert len(gateway.charges) == 1
19
+
20
+
21
+ def test_decline_is_terminal():
22
+ gateway = OfflineGateway(("declined", "ok"))
23
+ with pytest.raises(PaymentDeclined):
24
+ PaymentService(gateway).pay("order-c", 500)
25
+ assert len(gateway.calls) == 1
26
+ assert not gateway.charges
27
+
28
+
29
+ def test_transport_retries_are_bounded():
30
+ gateway = OfflineGateway(("timeout_before",) * 4)
31
+ with pytest.raises(GatewayTimeout):
32
+ PaymentService(gateway, max_attempts=2).pay("order-d", 500)
33
+ assert len(gateway.calls) == 2
34
+ assert not gateway.charges
@@ -0,0 +1,6 @@
1
+ # Debrief
2
+
3
+ - Which observation distinguishes a transport failure from a rejected payment?
4
+ - What lifetime should a logical request identity have?
5
+ - Which regression check catches a duplicate even if checkout returns success?
6
+ - What would a real provider need to guarantee for concurrent calls and restarts?
@@ -0,0 +1,3 @@
1
+ FROM python:3.12-slim
2
+ ENV PYTHONDONTWRITEBYTECODE=1
3
+ RUN pip install --no-cache-dir pytest==8.3.5
@@ -0,0 +1,11 @@
1
+ --- a/src/payments/service.py
2
+ +++ b/src/payments/service.py
3
+ @@ -14,7 +14,7 @@
4
+ if not isinstance(request_id, str) or not request_id.strip():
5
+ raise ValueError("request_id must be nonempty")
6
+ for attempt in range(self.max_attempts):
7
+ - provider_key = f"{request_id}:attempt-{attempt}"
8
+ + provider_key = request_id
9
+ try:
10
+ return self.gateway.charge(provider_key, amount_cents)
11
+ except GatewayTimeout:
@@ -0,0 +1,8 @@
1
+ [project]
2
+ name = "checkout-payments"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.12"
5
+
6
+ [tool.pytest.ini_options]
7
+ pythonpath = ["src"]
8
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """Offline checkout payment example."""
@@ -0,0 +1,38 @@
1
+ from collections import deque
2
+
3
+ from payments.models import GatewayTimeout, PaymentDeclined, Receipt, RequestConflict
4
+
5
+
6
+ class OfflineGateway:
7
+ """Deterministic provider simulator; state lasts for this instance only.
8
+
9
+ Events: ok, timeout_before, timeout_after, declined. An already committed
10
+ key returns its receipt before consuming any event. No network or credentials.
11
+ """
12
+
13
+ def __init__(self, events: tuple[str, ...] = ()) -> None:
14
+ self._events = deque(events)
15
+ self.charges: dict[str, Receipt] = {}
16
+ self.calls: list[tuple[str, int]] = []
17
+
18
+ def charge(self, key: str, amount_cents: int) -> Receipt:
19
+ if not key or type(amount_cents) is not int or amount_cents <= 0:
20
+ raise ValueError("key and positive integer amount_cents required")
21
+ self.calls.append((key, amount_cents))
22
+ if key in self.charges:
23
+ receipt = self.charges[key]
24
+ if receipt.amount_cents != amount_cents:
25
+ raise RequestConflict("request already used for another amount")
26
+ return receipt
27
+ event = self._events.popleft() if self._events else "ok"
28
+ if event == "timeout_before":
29
+ raise GatewayTimeout("connection lost before acceptance")
30
+ if event == "declined":
31
+ raise PaymentDeclined("payment declined")
32
+ if event not in {"ok", "timeout_after"}:
33
+ raise ValueError("unknown simulator event")
34
+ receipt = Receipt(f"charge-{len(self.charges) + 1:04d}", amount_cents)
35
+ self.charges[key] = receipt
36
+ if event == "timeout_after":
37
+ raise GatewayTimeout("response lost after acceptance")
38
+ return receipt
@@ -0,0 +1,19 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ class GatewayTimeout(TimeoutError):
5
+ """Transport outcome is unknown; retry is allowed."""
6
+
7
+
8
+ class PaymentDeclined(Exception):
9
+ """Terminal rejection; do not retry."""
10
+
11
+
12
+ class RequestConflict(Exception):
13
+ """A request identity was reused with different payment details."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Receipt:
18
+ charge_id: str
19
+ amount_cents: int
@@ -0,0 +1,23 @@
1
+ from payments.gateway import OfflineGateway
2
+ from payments.models import GatewayTimeout, Receipt
3
+
4
+
5
+ class PaymentService:
6
+ def __init__(self, gateway: OfflineGateway, max_attempts: int = 3) -> None:
7
+ if type(max_attempts) is not int or max_attempts < 1:
8
+ raise ValueError("max_attempts must be a positive integer")
9
+ self.gateway = gateway
10
+ self.max_attempts = max_attempts
11
+
12
+ def pay(self, request_id: str, amount_cents: int) -> Receipt:
13
+ """Return a receipt, retrying only ambiguous transport failures."""
14
+ if not isinstance(request_id, str) or not request_id.strip():
15
+ raise ValueError("request_id must be nonempty")
16
+ for attempt in range(self.max_attempts):
17
+ provider_key = f"{request_id}:attempt-{attempt}"
18
+ try:
19
+ return self.gateway.charge(provider_key, amount_cents)
20
+ except GatewayTimeout:
21
+ if attempt + 1 == self.max_attempts:
22
+ raise
23
+ raise AssertionError("unreachable")
@@ -0,0 +1,108 @@
1
+ # Let subscribers unsubscribe and listen once on the event bus
2
+
3
+ **Reported by:** CLI tooling team
4
+ **Component:** `localbus` (`src/bus.ts`)
5
+
6
+ ## Background
7
+
8
+ `EventBus` is a small synchronous in-process bus. Today `on(topic, handler)` registers a handler and
9
+ `emit(topic, payload)` calls the registered handlers in order and returns how many it called. There is
10
+ no way to take a subscription back, and no way to listen for a single event. Two stubs exist so callers
11
+ can already import the final API: `on` returns an unsubscribe function that does nothing, and `once`
12
+ throws "not implemented".
13
+
14
+ ## Request
15
+
16
+ Make `on` return a working unsubscribe function, implement `once`, and make `emit` safe when handlers
17
+ subscribe, unsubscribe, re-enter or throw. All other behavior must stay as it is.
18
+
19
+ ### Public API
20
+
21
+ ```ts
22
+ type Handler = (payload: unknown) => void;
23
+ type Unsubscribe = () => void;
24
+
25
+ class EventBus {
26
+ on(topic: string, handler: Handler): Unsubscribe;
27
+ once(topic: string, handler: Handler): Unsubscribe;
28
+ emit(topic: string, payload?: unknown): number;
29
+ }
30
+ ```
31
+
32
+ The checks run the code at runtime with Vitest. They do not run the TypeScript compiler, so they make
33
+ no claim about type correctness.
34
+
35
+ ### Scope
36
+
37
+ Everything is synchronous. There are no wildcards or patterns, no priorities and no compatibility
38
+ with Node's `EventEmitter`. A handler's return value is ignored: if it returns a promise, the bus does
39
+ not wait for it and does not look at it.
40
+
41
+ ### Clauses
42
+
43
+ **C1 Topics.** A topic is any string, compared exactly and case-sensitively; the empty string is a
44
+ valid topic. `*`, `.` and similar characters have no special meaning, so `emit("user.created")` never
45
+ reaches a handler registered for `"user.*"` or `"*"`. Separate `EventBus` instances share nothing.
46
+
47
+ **C2 Order and duplicates.** Handlers for a topic run in registration order, whether they were
48
+ registered with `on` or `once`. Registering the same function again, with `on` or `once`, creates an
49
+ independent subscription; it runs once per subscription per emit and has its own unsubscribe function.
50
+
51
+ **C3 Payload.** Each handler is called with exactly one argument: the `payload` given to `emit`, the
52
+ same value and reference (no copy). If `emit` is called without a payload, handlers receive
53
+ `undefined`. Falsy payloads (`0`, `""`, `false`, `null`) are delivered as they are.
54
+
55
+ **C4 Return value.** `emit` returns the number of handler invocations it made: `0` if the topic has no
56
+ subscribers. A `once` subscription that was skipped (C7) is not counted. If any handler throws, `emit`
57
+ throws (C8) and returns nothing.
58
+
59
+ **C5 Unsubscribe.** `on` and `once` return an unsubscribe function for that one subscription. Calling
60
+ it removes that subscription from later emits. Calling it again does nothing, and never removes
61
+ another subscription, even one that uses the same handler function. Unsubscribing a `once`
62
+ subscription that has already run does nothing. Order of the remaining subscriptions is unchanged.
63
+
64
+ **C6 Snapshot.** When `emit` starts it captures the subscriptions of that topic, in registration
65
+ order, and calls exactly those. Subscriptions added during the emit run from the next emit on.
66
+ Unsubscribing during the emit takes effect for later emits only: a captured subscription still gets its
67
+ turn in the emit that captured it, whether it was registered with `on` or `once`. A nested `emit`
68
+ (called from a handler) captures its own snapshot from the subscriptions that exist at that moment.
69
+
70
+ **C7 Once.** A `once` subscription runs at most once. It is consumed immediately before its handler
71
+ is called, so it stays consumed if the handler throws, and a handler that emits its own topic is not
72
+ called again. A captured `once` subscription whose turn comes after it was already consumed, for
73
+ example by a nested `emit`, is skipped and not counted. Being unsubscribed does not consume it (C6).
74
+
75
+ Example:
76
+
77
+ ```ts
78
+ const bus = new EventBus();
79
+ const log: string[] = [];
80
+ let nested = false;
81
+ bus.on("tick", () => {
82
+ log.push("first");
83
+ if (!nested) {
84
+ nested = true;
85
+ bus.emit("tick"); // returns 3: first, once, last
86
+ }
87
+ });
88
+ bus.once("tick", () => log.push("once"));
89
+ bus.on("tick", () => log.push("last"));
90
+ bus.emit("tick"); // returns 2: first, last (the once handler was consumed by the nested emit)
91
+ // log: first, first, once, last, last
92
+ ```
93
+
94
+ **C8 Errors.** A handler that throws does not stop later handlers of the same snapshot. After the last
95
+ captured subscription's turn, if any handler threw, `emit` throws an `AggregateError` whose `errors`
96
+ lists the thrown values in invocation order, unchanged (not wrapped, and not only `Error` objects).
97
+ One failure still produces an `AggregateError` with one entry. If a handler's own nested `emit` throws
98
+ an `AggregateError`, that `AggregateError` is a single entry. The message text is not specified. The
99
+ bus stays fully usable: consumed `once` subscriptions stay consumed, other subscriptions stay
100
+ registered, and changes made by handlers before or after a failure still apply.
101
+
102
+ ## Notes
103
+
104
+ - Keep `src/bus.ts` as the module that exports `EventBus`, `Handler` and `Unsubscribe`.
105
+ - Run `pgnd check` from the session workspace to check your changes in Docker.
106
+ - Add tests for the behavior you implement and keep the existing behavior unchanged.
107
+ - You may use your own editor and AI tools. The goal is to build and explain a precise change, not
108
+ only to make the listed cases pass.