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,41 @@
1
+ diff --git a/src/profilecache/cache.py b/src/profilecache/cache.py
2
+ index 497b49d..90f2a16 100644
3
+ --- a/src/profilecache/cache.py
4
+ +++ b/src/profilecache/cache.py
5
+ @@ -13,11 +13,12 @@ Clock = Callable[[], float]
6
+ @dataclass
7
+ class _Entry:
8
+ value: Any
9
+ - ttl: float
10
+ deadline: float
11
+
12
+
13
+ def _validate_ttl(ttl: float) -> float:
14
+ + if not ttl > 0:
15
+ + raise ValueError(f"ttl must be positive, got {ttl!r}")
16
+ return ttl
17
+
18
+
19
+ @@ -36,7 +37,7 @@ class TTLCache:
20
+ def set(self, key: Hashable, value: Any, ttl: float | None = None) -> None:
21
+ """Store ``value`` under ``key`` for ``ttl`` seconds (default: the cache default)."""
22
+ ttl = _validate_ttl(self._default_ttl if ttl is None else ttl)
23
+ - self._entries[key] = _Entry(value, ttl, self._clock() + ttl)
24
+ + self._entries[key] = _Entry(value, self._clock() + ttl)
25
+
26
+ def get(self, key: Hashable, default: Any = None) -> Any:
27
+ """Return the live value for ``key``, or ``default`` if absent or expired."""
28
+ @@ -47,7 +48,6 @@ class TTLCache:
29
+ if self._expired(entry, now):
30
+ del self._entries[key]
31
+ return default
32
+ - entry.deadline = now + entry.ttl
33
+ return entry.value
34
+
35
+ def __contains__(self, key: Hashable) -> bool:
36
+ @@ -64,4 +64,4 @@ class TTLCache:
37
+
38
+ @staticmethod
39
+ def _expired(entry: _Entry, now: float) -> bool:
40
+ - return now > entry.deadline
41
+ + return now >= entry.deadline
@@ -0,0 +1,8 @@
1
+ [project]
2
+ name = "profilecache"
3
+ version = "0.2.0"
4
+ requires-python = ">=3.12"
5
+
6
+ [tool.pytest.ini_options]
7
+ pythonpath = ["src"]
8
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ """In-memory caching for user profiles."""
2
+
3
+ from profilecache.cache import TTLCache
4
+ from profilecache.client import ProfileClient
5
+
6
+ __all__ = ["ProfileClient", "TTLCache"]
@@ -0,0 +1,67 @@
1
+ """A small in-memory cache whose entries expire after a time to live (TTL)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable, Hashable
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ Clock = Callable[[], float]
11
+
12
+
13
+ @dataclass
14
+ class _Entry:
15
+ value: Any
16
+ ttl: float
17
+ deadline: float
18
+
19
+
20
+ def _validate_ttl(ttl: float) -> float:
21
+ return ttl
22
+
23
+
24
+ class TTLCache:
25
+ """Maps keys to values for a limited time.
26
+
27
+ Time comes from an injectable ``clock`` returning seconds, so callers and
28
+ tests control it. Expired entries are dropped lazily when they are looked at.
29
+ """
30
+
31
+ def __init__(self, default_ttl: float = 60.0, clock: Clock = time.monotonic) -> None:
32
+ self._default_ttl = _validate_ttl(default_ttl)
33
+ self._clock = clock
34
+ self._entries: dict[Hashable, _Entry] = {}
35
+
36
+ def set(self, key: Hashable, value: Any, ttl: float | None = None) -> None:
37
+ """Store ``value`` under ``key`` for ``ttl`` seconds (default: the cache default)."""
38
+ ttl = _validate_ttl(self._default_ttl if ttl is None else ttl)
39
+ self._entries[key] = _Entry(value, ttl, self._clock() + ttl)
40
+
41
+ def get(self, key: Hashable, default: Any = None) -> Any:
42
+ """Return the live value for ``key``, or ``default`` if absent or expired."""
43
+ entry = self._entries.get(key)
44
+ if entry is None:
45
+ return default
46
+ now = self._clock()
47
+ if self._expired(entry, now):
48
+ del self._entries[key]
49
+ return default
50
+ entry.deadline = now + entry.ttl
51
+ return entry.value
52
+
53
+ def __contains__(self, key: Hashable) -> bool:
54
+ entry = self._entries.get(key)
55
+ return entry is not None and not self._expired(entry, self._clock())
56
+
57
+ def purge_expired(self) -> int:
58
+ """Drop every expired entry and return how many were removed."""
59
+ now = self._clock()
60
+ stale = [key for key, entry in self._entries.items() if self._expired(entry, now)]
61
+ for key in stale:
62
+ del self._entries[key]
63
+ return len(stale)
64
+
65
+ @staticmethod
66
+ def _expired(entry: _Entry, now: float) -> bool:
67
+ return now > entry.deadline
@@ -0,0 +1,38 @@
1
+ """Profile client that avoids refetching recently loaded profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from profilecache.cache import TTLCache
9
+
10
+ Profile = dict[str, Any]
11
+
12
+ _MISSING = object()
13
+
14
+
15
+ class ProfileClient:
16
+ """Loads profiles through ``fetch`` and keeps them in a cache for ``ttl`` seconds."""
17
+
18
+ def __init__(
19
+ self,
20
+ fetch: Callable[[str], Profile],
21
+ cache: TTLCache | None = None,
22
+ ttl: float = 30.0,
23
+ ) -> None:
24
+ self._fetch = fetch
25
+ self._cache = cache if cache is not None else TTLCache()
26
+ self._ttl = ttl
27
+
28
+ def get(self, user_id: str) -> Profile:
29
+ cached = self._cache.get(user_id, _MISSING)
30
+ if cached is not _MISSING:
31
+ return cached
32
+ profile = self._fetch(user_id)
33
+ self._cache.set(user_id, profile, ttl=self._ttl)
34
+ return profile
35
+
36
+ def update(self, user_id: str, profile: Profile) -> None:
37
+ """Replace the cached profile after the user edited it."""
38
+ self._cache.set(user_id, profile, ttl=self._ttl)
@@ -0,0 +1,97 @@
1
+ # Let the stock importer report bad rows instead of stopping at the first one
2
+
3
+ **Reported by:** warehouse operations
4
+ **Component:** `stockimport`
5
+
6
+ ## Background
7
+
8
+ `import_inventory(text)` reads the nightly stock feed (`sku,quantity,unit_price`) and returns a list
9
+ of `Item`. It stops with `ImportFailed` at the first problem, so a feed with many bad rows takes many
10
+ re-uploads to fix. The feed data is synthetic in this exercise.
11
+
12
+ ## Request
13
+
14
+ Add a non-strict mode that returns every valid item together with one error per rejected row. The
15
+ default behavior must not change.
16
+
17
+ ### Public API
18
+
19
+ ```python
20
+ import_inventory(text: str, *, strict: bool = True) -> list[Item] | ImportResult
21
+ ```
22
+
23
+ - `strict=True` (the default) returns `list[Item]` exactly as today.
24
+ - `strict=False` returns `ImportResult(items: list[Item], errors: list[RowError])`.
25
+ - `strict` is keyword-only. `Item`, `RowError`, `ImportResult` and `ImportFailed` already exist in
26
+ `stockimport/models.py` and are exported from `stockimport`; keep their fields and names.
27
+ - `Item(sku: str, quantity: int, unit_price_cents: int)`.
28
+ - `RowError(line: int, column: str | None, code: str, message: str)`. `message` is free human text
29
+ and is not part of the contract.
30
+ - `ImportFailed` subclasses `ValueError`, carries the `RowError` as `.error`, and its text is
31
+ `"line <line>: <message>"`.
32
+
33
+ ### Input format and line numbers
34
+
35
+ The input is a string. The first physical line is the header; the rest are records. A quoted field
36
+ may contain line breaks, so a record can span several physical lines. Line numbers are 1-based
37
+ physical lines of the text (header = line 1). A record's line is the physical line **where it
38
+ starts**, and the next record's line counts every physical line before it.
39
+
40
+ ### Rules
41
+
42
+ 1. **Header.** The first record must be exactly `sku`, `quantity`, `unit_price` (case-sensitive, in
43
+ that order, nothing else). Otherwise, including empty text or a blank first line, the import
44
+ raises `ImportFailed` with `RowError(line=1, column=None, code="bad_header", ...)`, in both modes.
45
+ 2. **Malformed CSV.** Only two syntax problems are recognized after the header: text other than a
46
+ comma or line end right after a closing quote, and a quoted field never closed before the end of
47
+ the text. Either raises `ImportFailed` with `code="malformed_csv"`, `column=None` and `line` the
48
+ starting line of the record that contains the problem, in both modes. Non-strict mode returns no
49
+ partial result in this case. Strict mode still raises the first row error that appears earlier in
50
+ the text, if there is one.
51
+ 3. **Empty lines.** A physical line with no characters at all (outside a quoted field) is skipped
52
+ and is not an error. It still counts toward line numbers. A line holding only spaces or `""` is a
53
+ record and is judged by rule 5.
54
+ 4. **One error per row.** A rejected row produces exactly one `RowError`, for the first check below
55
+ that fails. Fields are never trimmed.
56
+ 5. **Checks, in this order**
57
+ 1. `wrong_field_count` (column `None`): the record does not have exactly 3 fields.
58
+ 2. `invalid_sku` (column `"sku"`): not one or more of `A-Z`, `0-9` and `-`.
59
+ 3. `duplicate_sku` (column `"sku"`): an earlier row in the same text was accepted with this sku.
60
+ 4. `invalid_quantity` (column `"quantity"`): not one or more ASCII digits `0-9`. Leading zeros are
61
+ fine (`"007"` is 7). No sign, no spaces, no decimals, no exponent.
62
+ 5. `invalid_price` (column `"unit_price"`): not ASCII digits optionally followed by `.` and one
63
+ or two ASCII digits (`"12"`, `"12.5"`, `"12.50"`, `"0.29"` are valid; `".5"`, `"5."`, `"12.345"`,
64
+ `"-1"`, `"1e3"`, `"1,000.00"` are not).
65
+ 6. **Cents.** `unit_price_cents` is the exact decimal amount times 100, as an `int`, computed
66
+ without floating point: `"12"` → 1200, `"12.5"` → 1250, `"0.29"` → 29, `"19.99"` → 1999.
67
+ 7. **Duplicates.** The first accepted row for a sku wins and its `Item` is never changed. A row
68
+ rejected for any reason does not reserve its sku, so a later valid row with the same sku is
69
+ accepted. A `duplicate_sku` row is itself rejected.
70
+ 8. **Order.** `items` are in file order. `errors` are in file order (ascending `line`).
71
+ 9. **Strict mode** raises `ImportFailed` for the first error in file order that the non-strict mode
72
+ would report (rules 1–5 and 7 apply identically); it never reports a later error instead.
73
+ 10. **Nothing accepted.** A text with a valid header and no valid rows gives `items == []` in
74
+ non-strict mode.
75
+
76
+ ## Example
77
+
78
+ ```text
79
+ sku,quantity,unit_price
80
+ A-1,5,19.99
81
+ A-2,x,1.00
82
+ B-1,2,0.29
83
+
84
+ A-1,9,1.00
85
+ ```
86
+
87
+ `import_inventory(text, strict=False)` returns items `A-1` (5 × 1999) and `B-1` (2 × 29), and errors
88
+ `(line=3, column="quantity", code="invalid_quantity")` and `(line=6, column="sku", code="duplicate_sku")`.
89
+ `import_inventory(text)` raises `ImportFailed` for line 3.
90
+
91
+ ## Notes
92
+
93
+ - Keep the single-purpose importer in `importer.py`; do not add dependencies.
94
+ - Run `pgnd check` from the session workspace to check your changes in Docker.
95
+ - Add tests for the behavior you implement and keep strict mode unchanged.
96
+ - You may use your own editor and AI tools. The goal is to build and explain a precise change, not
97
+ only to make the listed cases pass.
@@ -0,0 +1,33 @@
1
+ spec_version: "0.1"
2
+ id: csv-import-row-errors
3
+ version: 1.0.1
4
+ title: Report bad rows from the stock importer instead of stopping at the first
5
+ type: feature
6
+ summary: >-
7
+ A strict CSV stock importer aborts at the first bad row. Add a non-strict mode that returns the
8
+ valid items and one precise error per rejected row, keeping strict behavior unchanged.
9
+ languages: [python]
10
+ tags: [csv, validation, parsing, error-handling]
11
+ difficulty: intermediate
12
+ timebox_minutes: 45
13
+ ai_policy: allowed
14
+ environment:
15
+ dockerfile: environment/Dockerfile
16
+ services: []
17
+ test_command: pytest -q -p no:cacheprovider --junitxml=$PG_RESULTS/junit.xml
18
+ checks:
19
+ visible: checks/visible
20
+ hidden: checks/hidden
21
+ solution: solution/reference.patch
22
+ expected_paths: [src/stockimport/, tests/]
23
+ hints:
24
+ - Decide where a failure for one row should stop and where it should only be recorded. Which failures can never be limited to one row?
25
+ - Look at when a sku is recorded as seen, and at what must be true of a row before it may reserve that sku.
26
+ - Keep one place that judges a row. Strict and non-strict mode should differ only in what happens to the error it produces.
27
+ debrief:
28
+ questions: debrief/questions.md
29
+ focus: [requirements_analysis, test_quality, scope_judgment]
30
+ author: {name: Patchground contributors}
31
+ content_license: CC-BY-4.0
32
+ provenance: generated
33
+ generated_by: {model: "Claude Code via Orca worker (model unverified)", prompt_version: launch-csv-import-v1}
@@ -0,0 +1,359 @@
1
+ """Hidden checks. Each group names the TICKET rule it covers."""
2
+
3
+ import pytest
4
+
5
+ from stockimport import ImportFailed, ImportResult, Item, RowError, import_inventory
6
+
7
+ HEADER = "sku,quantity,unit_price"
8
+
9
+
10
+ def doc(*lines, end="\n"):
11
+ return "\n".join((HEADER, *lines)) + end
12
+
13
+
14
+ def lenient(text):
15
+ return import_inventory(text, strict=False)
16
+
17
+
18
+ def shape(errors):
19
+ return [(e.line, e.column, e.code) for e in errors]
20
+
21
+
22
+ def strict_error(text):
23
+ with pytest.raises(ImportFailed) as caught:
24
+ import_inventory(text)
25
+ assert isinstance(caught.value.error, RowError)
26
+ return caught.value.error
27
+
28
+
29
+ # --- Public API -------------------------------------------------------------------------------
30
+
31
+
32
+ def test_api_non_strict_returns_import_result_with_lists():
33
+ result = lenient(doc("A-1,1,1.00"))
34
+ assert isinstance(result, ImportResult)
35
+ assert isinstance(result.items, list) and isinstance(result.errors, list)
36
+ assert result.items == [Item("A-1", 1, 100)]
37
+ assert result.errors == []
38
+
39
+
40
+ def test_api_default_and_explicit_strict_return_a_plain_list():
41
+ text = doc("A-1,1,1.00", "B-2,3,2.50")
42
+ expected = [Item("A-1", 1, 100), Item("B-2", 3, 250)]
43
+ assert import_inventory(text) == expected
44
+ assert import_inventory(text, strict=True) == expected
45
+
46
+
47
+ def test_api_strict_is_keyword_only():
48
+ with pytest.raises(TypeError):
49
+ import_inventory(doc("A-1,1,1.00"), False)
50
+
51
+
52
+ def test_api_import_failed_text_and_type():
53
+ with pytest.raises(ValueError, match=r"^line 2: ") as caught:
54
+ import_inventory(doc("A-1,x,1.00"))
55
+ assert isinstance(caught.value, ImportFailed)
56
+ assert caught.value.error.line == 2
57
+
58
+
59
+ # --- Rule 1: header -----------------------------------------------------------------------------
60
+
61
+ BAD_HEADERS = [
62
+ pytest.param("", id="empty-text"),
63
+ pytest.param("\n", id="blank-first-line"),
64
+ pytest.param("\nsku,quantity,unit_price\nA-1,1,1.00\n", id="blank-then-header"),
65
+ pytest.param("sku,quantity\nA-1,1\n", id="missing-column"),
66
+ pytest.param("sku,quantity,unit_price,extra\nA-1,1,1.00,x\n", id="extra-column"),
67
+ pytest.param("sku,qty,unit_price\nA-1,1,1.00\n", id="renamed-column"),
68
+ pytest.param("quantity,sku,unit_price\n1,A-1,1.00\n", id="reordered"),
69
+ pytest.param("SKU,quantity,unit_price\nA-1,1,1.00\n", id="case-sensitive"),
70
+ pytest.param(" sku,quantity,unit_price\nA-1,1,1.00\n", id="not-trimmed"),
71
+ ]
72
+
73
+
74
+ @pytest.mark.parametrize("text", BAD_HEADERS)
75
+ @pytest.mark.parametrize("strict", [True, False])
76
+ def test_rule1_bad_header_fails_in_both_modes(text, strict):
77
+ with pytest.raises(ImportFailed) as caught:
78
+ import_inventory(text, strict=strict)
79
+ error = caught.value.error
80
+ assert (error.line, error.column, error.code) == (1, None, "bad_header")
81
+
82
+
83
+ def test_rule1_header_only_and_crlf_header_are_valid():
84
+ assert lenient(HEADER + "\n") == ImportResult([], [])
85
+ assert lenient(HEADER) == ImportResult([], [])
86
+ result = lenient(HEADER + "\r\nA-1,2,3.00\r\n")
87
+ assert result.items == [Item("A-1", 2, 300)] and result.errors == []
88
+
89
+
90
+ # --- Rule 2: malformed CSV ----------------------------------------------------------------------
91
+
92
+
93
+ def test_rule2_unterminated_quote_reports_the_record_start_line():
94
+ text = doc("A-1,1,1.00", 'A-2,"5,1.00', "A-3,1,1.00")
95
+ for strict in (True, False):
96
+ with pytest.raises(ImportFailed) as caught:
97
+ import_inventory(text, strict=strict)
98
+ error = caught.value.error
99
+ assert (error.line, error.column, error.code) == (3, None, "malformed_csv")
100
+
101
+
102
+ def test_rule2_text_after_closing_quote_after_a_multiline_record():
103
+ text = doc('"A\nB",1,1.00', '"A-1"x,1,1.00')
104
+ # header = 1, multi-line record = lines 2-3, the broken record starts on line 4
105
+ with pytest.raises(ImportFailed) as caught:
106
+ lenient(text)
107
+ error = caught.value.error
108
+ assert (error.line, error.column, error.code) == (4, None, "malformed_csv")
109
+
110
+
111
+ def test_rule2_non_strict_returns_no_partial_result():
112
+ text = doc("A-1,1,1.00", "B-2,x,1.00", '"C-3"junk,1,1.00')
113
+ with pytest.raises(ImportFailed) as caught:
114
+ lenient(text)
115
+ assert caught.value.error.code == "malformed_csv"
116
+
117
+
118
+ def test_rule2_strict_reports_an_earlier_row_error_before_the_syntax_error():
119
+ text = doc("A-1,1,1.00", "B-2,x,1.00", '"C-3"junk,1,1.00')
120
+ error = strict_error(text)
121
+ assert (error.line, error.code) == (3, "invalid_quantity")
122
+
123
+
124
+ # --- Rule 3: empty lines ------------------------------------------------------------------------
125
+
126
+
127
+ def test_rule3_empty_lines_are_skipped_but_counted():
128
+ text = doc("A-1,1,1.00", "", "", "B-2,x,1.00", "", "C-3,1,1.00", "", end="\n\n")
129
+ result = lenient(text)
130
+ assert [i.sku for i in result.items] == ["A-1", "C-3"]
131
+ assert shape(result.errors) == [(5, "quantity", "invalid_quantity")]
132
+
133
+
134
+ def test_rule3_crlf_empty_lines_are_skipped_and_counted():
135
+ text = HEADER + "\r\n\r\nA-1,x,1.00\r\n\r\nB-2,1,1.00\r\n"
136
+ result = lenient(text)
137
+ assert [i.sku for i in result.items] == ["B-2"]
138
+ assert shape(result.errors) == [(3, "quantity", "invalid_quantity")]
139
+
140
+
141
+ @pytest.mark.parametrize("line", [" ", '""', "\t"], ids=repr)
142
+ def test_rule3_lines_that_are_not_empty_are_records(line):
143
+ result = lenient(doc("A-1,1,1.00", line))
144
+ assert shape(result.errors) == [(3, None, "wrong_field_count")]
145
+
146
+
147
+ # --- Rules 4 and 5: one error per row, check order ----------------------------------------------
148
+
149
+
150
+ def test_rule5_wrong_field_count_wins_over_everything():
151
+ result = lenient(doc("a b,1", "A-1,1,1.00,extra", "A-2"))
152
+ assert shape(result.errors) == [
153
+ (2, None, "wrong_field_count"),
154
+ (3, None, "wrong_field_count"),
155
+ (4, None, "wrong_field_count"),
156
+ ]
157
+ assert result.items == []
158
+
159
+
160
+ ORDER_CASES = [
161
+ pytest.param(["bad sku,x,y"], "invalid_sku", "sku", id="sku-before-quantity-and-price"),
162
+ pytest.param(["A-1,1,1.00", "A-1,x,y"], "duplicate_sku", "sku", id="duplicate-before-quantity"),
163
+ pytest.param(["A-1,1,1.00", "A-1,1,y"], "duplicate_sku", "sku", id="duplicate-before-price"),
164
+ pytest.param(["A-1,x,y"], "invalid_quantity", "quantity", id="quantity-before-price"),
165
+ pytest.param(["A-1,1,y"], "invalid_price", "unit_price", id="price-last"),
166
+ ]
167
+
168
+
169
+ @pytest.mark.parametrize(("rows", "code", "column"), ORDER_CASES)
170
+ def test_rule5_each_row_gets_only_its_first_failing_check(rows, code, column):
171
+ result = lenient(doc(*rows))
172
+ assert len(result.errors) == 1
173
+ assert (result.errors[0].code, result.errors[0].column) == (code, column)
174
+ assert result.errors[0].line == len(rows) + 1
175
+
176
+
177
+ @pytest.mark.parametrize("sku", ["", "a-1", "A 1", " A-1", "A-1 ", "A_1", "É-1", "A\n1"], ids=repr)
178
+ def test_rule5_invalid_sku(sku):
179
+ text = HEADER + "\n" + '"' + sku + '"' + ",1,1.00\n"
180
+ result = lenient(text)
181
+ assert result.items == []
182
+ assert shape(result.errors) == [(2, "sku", "invalid_sku")]
183
+
184
+
185
+ @pytest.mark.parametrize("sku", ["A", "0", "-", "AB-12-C", "007"], ids=repr)
186
+ def test_rule5_valid_sku(sku):
187
+ assert lenient(doc(f"{sku},1,1.00")).items == [Item(sku, 1, 100)]
188
+
189
+
190
+ @pytest.mark.parametrize("quantity", ["", "-1", "+2", "1.5", "1e2", " 3", "3 ", "٣", "12", "0x10"], ids=repr)
191
+ def test_rule5_invalid_quantity(quantity):
192
+ text = HEADER + "\n" + 'A-1,"' + quantity + '",1.00\n'
193
+ assert shape(lenient(text).errors) == [(2, "quantity", "invalid_quantity")]
194
+
195
+
196
+ @pytest.mark.parametrize(("quantity", "value"), [("0", 0), ("7", 7), ("007", 7), ("1000000", 1000000)], ids=repr)
197
+ def test_rule5_valid_quantity(quantity, value):
198
+ assert lenient(doc(f"A-1,{quantity},1.00")).items == [Item("A-1", value, 100)]
199
+
200
+
201
+ BAD_PRICES = ["", ".5", "5.", "12.345", "-1", "+1", "1e3", "1,000.00", " 1", "1 ", "1.0.0", "NaN", "inf", "١٢", "1.٥", "$1"]
202
+
203
+
204
+ @pytest.mark.parametrize("price", BAD_PRICES, ids=repr)
205
+ def test_rule5_invalid_price(price):
206
+ text = HEADER + "\n" + 'A-1,1,"' + price + '"\n'
207
+ result = lenient(text)
208
+ assert result.items == []
209
+ assert shape(result.errors) == [(2, "unit_price", "invalid_price")]
210
+
211
+
212
+ # --- Rule 6: exact cents ------------------------------------------------------------------------
213
+
214
+ CENTS = [
215
+ ("0", 0),
216
+ ("0.00", 0),
217
+ ("12", 1200),
218
+ ("12.5", 1250),
219
+ ("12.50", 1250),
220
+ ("12.05", 1205),
221
+ ("0.1", 10),
222
+ ("0.01", 1),
223
+ ("0.29", 29),
224
+ ("1.15", 115),
225
+ ("19.99", 1999),
226
+ ("4.35", 435),
227
+ ("1234567.89", 123456789),
228
+ ("90071992547409.93", 9007199254740993),
229
+ ]
230
+
231
+
232
+ @pytest.mark.parametrize(("price", "cents"), CENTS, ids=repr)
233
+ def test_rule6_prices_become_exact_integer_cents(price, cents):
234
+ (item,) = lenient(doc(f"A-1,1,{price}")).items
235
+ assert item.unit_price_cents == cents
236
+ assert type(item.unit_price_cents) is int
237
+
238
+
239
+ @pytest.mark.parametrize(("price", "cents"), CENTS, ids=repr)
240
+ def test_rule6_strict_and_non_strict_agree_on_cents(price, cents):
241
+ assert import_inventory(doc(f"A-1,1,{price}")) == [Item("A-1", 1, cents)]
242
+
243
+
244
+ # --- Rule 7: duplicates -------------------------------------------------------------------------
245
+
246
+
247
+ def test_rule7_first_accepted_row_wins():
248
+ result = lenient(doc("A-1,5,1.00", "A-1,9,9.00", "A-1,7,7.00"))
249
+ assert result.items == [Item("A-1", 5, 100)]
250
+ assert shape(result.errors) == [(3, "sku", "duplicate_sku"), (4, "sku", "duplicate_sku")]
251
+
252
+
253
+ @pytest.mark.parametrize(
254
+ "bad_row",
255
+ [
256
+ pytest.param("A-1,x,1.00", id="bad-quantity"),
257
+ pytest.param("A-1,1,1.005", id="bad-price"),
258
+ pytest.param("A-1,1", id="wrong-field-count"),
259
+ pytest.param("A-1,-1,x", id="both-bad"),
260
+ ],
261
+ )
262
+ def test_rule7_a_rejected_row_does_not_reserve_its_sku(bad_row):
263
+ result = lenient(doc(bad_row, "A-1,5,2.50"))
264
+ assert result.items == [Item("A-1", 5, 250)]
265
+ assert len(result.errors) == 1 and result.errors[0].line == 2
266
+
267
+
268
+ def test_rule7_a_multiline_rejected_row_does_not_reserve_its_sku():
269
+ result = lenient(doc('A-1,"1\n2",1.00', "A-1,3,1.00"))
270
+ assert result.items == [Item("A-1", 3, 100)]
271
+ assert shape(result.errors) == [(2, "quantity", "invalid_quantity")]
272
+
273
+
274
+ def test_rule7_duplicates_ignore_blank_lines_between_rows():
275
+ result = lenient(doc("A-1,1,1.00", "", "A-1,2,2.00"))
276
+ assert result.items == [Item("A-1", 1, 100)]
277
+ assert shape(result.errors) == [(4, "sku", "duplicate_sku")]
278
+
279
+
280
+ # --- Lines of multi-line records (rules in "Input format and line numbers") -----------------------
281
+
282
+
283
+ def test_lines_multiline_record_reports_its_start_and_shifts_later_lines():
284
+ text = doc('"A\nB",1,1.00', "C-1,x,1.00", 'D-1,"1\n2\n3",1.00', "E-1,x,1.00")
285
+ result = lenient(text)
286
+ assert shape(result.errors) == [
287
+ (2, "sku", "invalid_sku"),
288
+ (4, "quantity", "invalid_quantity"),
289
+ (5, "quantity", "invalid_quantity"),
290
+ (8, "quantity", "invalid_quantity"),
291
+ ]
292
+ assert result.items == []
293
+
294
+
295
+ def test_lines_multiline_record_with_crlf():
296
+ text = HEADER + '\r\n"A\r\nB",1,1.00\r\nC-1,x,1.00\r\n'
297
+ assert shape(lenient(text).errors) == [(2, "sku", "invalid_sku"), (4, "quantity", "invalid_quantity")]
298
+
299
+
300
+ # --- Rule 8: order ------------------------------------------------------------------------------
301
+
302
+
303
+ def test_rule8_items_and_errors_are_in_file_order():
304
+ text = doc("Z-9,1,1.00", "bad,1,1.00", "A-1,1,1.00", "A-1,x,1", "M-5,2,3.5", "M-5,1,1", "B-2,y,1.00")
305
+ result = lenient(text)
306
+ assert [i.sku for i in result.items] == ["Z-9", "A-1", "M-5"]
307
+ lines = [e.line for e in result.errors]
308
+ assert lines == sorted(lines) == [3, 5, 7, 8]
309
+ assert [e.code for e in result.errors] == ["invalid_sku", "duplicate_sku", "duplicate_sku", "invalid_quantity"]
310
+
311
+
312
+ def test_rule8_ticket_example():
313
+ text = "sku,quantity,unit_price\nA-1,5,19.99\nA-2,x,1.00\nB-1,2,0.29\n\nA-1,9,1.00\n"
314
+ result = lenient(text)
315
+ assert result.items == [Item("A-1", 5, 1999), Item("B-1", 2, 29)]
316
+ assert shape(result.errors) == [(3, "quantity", "invalid_quantity"), (6, "sku", "duplicate_sku")]
317
+ assert strict_error(text).line == 3
318
+
319
+
320
+ # --- Rule 9: strict mode ------------------------------------------------------------------------
321
+
322
+
323
+ def test_rule9_strict_raises_the_first_error_not_the_last():
324
+ error = strict_error(doc("A-1,1,1.00", "B-2,x,1.00", "C-3,1,y", "D-4"))
325
+ assert (error.line, error.column, error.code) == (3, "quantity", "invalid_quantity")
326
+
327
+
328
+ @pytest.mark.parametrize(
329
+ "rows",
330
+ [
331
+ pytest.param(["A-1,1,1.00", "A-1,2,2.00"], id="duplicate"),
332
+ pytest.param(["", "", 'A-1,"1\n2",1.00', "bad"], id="multiline-after-blank-lines"),
333
+ pytest.param(["A-1", "B-2,x,y"], id="field-count-first"),
334
+ pytest.param(["A-1,1,1.005"], id="price"),
335
+ ],
336
+ )
337
+ def test_rule9_strict_error_equals_first_non_strict_error(rows):
338
+ text = doc(*rows)
339
+ assert strict_error(text) == lenient(text).errors[0]
340
+
341
+
342
+ def test_rule9_strict_stops_at_the_first_rejected_row_even_if_a_later_row_is_valid():
343
+ error = strict_error(doc("A-1,x,1.00", "A-1,1,1.00", "B-2,1,y"))
344
+ assert (error.line, error.code) == (2, "invalid_quantity")
345
+
346
+
347
+ # --- Rule 10: nothing accepted ------------------------------------------------------------------
348
+
349
+
350
+ def test_rule10_all_rows_rejected_gives_empty_items_and_all_errors():
351
+ result = lenient(doc("a,1,1.00", "B-1,x,1.00", "C-1,1,x", "D-1"))
352
+ assert result.items == []
353
+ assert [e.line for e in result.errors] == [2, 3, 4, 5]
354
+
355
+
356
+ def test_rule10_non_strict_does_not_raise_for_row_errors():
357
+ result = lenient(doc("???,???,???"))
358
+ assert isinstance(result, ImportResult)
359
+ assert len(result.errors) == 1