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.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- 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,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
|