bantamkit 0.27.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.
- bantamkit/__init__.py +32 -0
- bantamkit/agent.py +458 -0
- bantamkit/assets/contracts/default.yaml +90 -0
- bantamkit/assets/evals/devteam/manifest.yaml +351 -0
- bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
- bantamkit/assets/evals/devteam/repo/README.md +12 -0
- bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
- bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
- bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- bantamkit/assets/evals/fixtures/.gitkeep +0 -0
- bantamkit/assets/evals/fixtures/catalog.json +6 -0
- bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
- bantamkit/assets/evals/tasks/.gitkeep +0 -0
- bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
- bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
- bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
- bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
- bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
- bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
- bantamkit/assets/profiles/default.yaml +31 -0
- bantamkit/assets/profiles/patient.yaml +31 -0
- bantamkit/assets/rubrics/.gitkeep +0 -0
- bantamkit/assets/rubrics/code-quality.yaml +20 -0
- bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
- bantamkit/assets/rubrics/task-completion.yaml +28 -0
- bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
- bantamkit/assets/skills/.gitkeep +0 -0
- bantamkit/assets/skills/file-graph.md +7 -0
- bantamkit/assets/skills/memory.md +35 -0
- bantamkit/assets/tools/.gitkeep +0 -0
- bantamkit/assets/tools/bantamkit_read.json +48 -0
- bantamkit/assets/tools/bantamkit_status.json +25 -0
- bantamkit/assets/tools/build_identity.json +17 -0
- bantamkit/assets/tools/document_list.json +12 -0
- bantamkit/assets/tools/document_read.json +31 -0
- bantamkit/assets/tools/file_graph.json +12 -0
- bantamkit/assets/tools/memory_compact.json +31 -0
- bantamkit/assets/tools/memory_recall.json +38 -0
- bantamkit/assets/tools/memory_save.json +61 -0
- bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
- bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
- bantamkit/assets/tools/shiftwork_status.json +25 -0
- bantamkit/assets/tools/skill_audit.json +70 -0
- bantamkit/assets/tools/validate_json.json +31 -0
- bantamkit/assets.py +67 -0
- bantamkit/budget.py +114 -0
- bantamkit/client.py +329 -0
- bantamkit/contract.py +522 -0
- bantamkit/criticreplay.py +3241 -0
- bantamkit/critique.py +301 -0
- bantamkit/docread.py +1744 -0
- bantamkit/evalrun.py +2003 -0
- bantamkit/eventlog.py +282 -0
- bantamkit/filegraph.py +218 -0
- bantamkit/loopguard.py +101 -0
- bantamkit/mcpreport.py +763 -0
- bantamkit/mcpserver.py +1334 -0
- bantamkit/memory/__init__.py +28 -0
- bantamkit/memory/__main__.py +291 -0
- bantamkit/memory/component.py +569 -0
- bantamkit/memory/divergence.py +744 -0
- bantamkit/memory/layers.py +257 -0
- bantamkit/memory/store.py +940 -0
- bantamkit/pdfread.py +1402 -0
- bantamkit/profile.py +46 -0
- bantamkit/shiftwork.py +212 -0
- bantamkit/skillaudit.py +853 -0
- bantamkit/statusline.py +313 -0
- bantamkit/structured.py +125 -0
- bantamkit/textutil.py +30 -0
- bantamkit-0.27.0.dist-info/METADATA +207 -0
- bantamkit-0.27.0.dist-info/RECORD +119 -0
- bantamkit-0.27.0.dist-info/WHEEL +4 -0
- bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# GENERATED by tools/devteam/build_tasks.py from assets/evals/devteam/manifest.yaml.
|
|
2
|
+
# Do not hand-edit: `build_tasks.py check` fails if you do. The task's selection
|
|
3
|
+
# rationale, reference walk and exclusion record live in the manifest.
|
|
4
|
+
name: dt-error-contract
|
|
5
|
+
family: dev-repo-code
|
|
6
|
+
tools:
|
|
7
|
+
- read_file
|
|
8
|
+
- list_files
|
|
9
|
+
workspace:
|
|
10
|
+
HISTORY.md: '# Release history
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
`git log --oneline --date=short --pretty=''%h %ad %s''`, newest first. Patch files
|
|
14
|
+
|
|
15
|
+
for selected commits are under `patches/`.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
9f2c1ab 2026-07-29 fix(retry): read retry_max_attempts from settings, not the constant
|
|
21
|
+
|
|
22
|
+
7d4e88c 2026-07-22 feat(settle): batch settlement with per-entry retry
|
|
23
|
+
|
|
24
|
+
6b1a903 2026-07-15 refactor(config): move every tunable into CONFIG_KEYS
|
|
25
|
+
|
|
26
|
+
5c0de41 2026-07-08 fix(validate): reject zero-amount entries
|
|
27
|
+
|
|
28
|
+
4a9bb27 2026-07-01 feat(report): render a per-handler summary
|
|
29
|
+
|
|
30
|
+
3e88f10 2026-06-24 feat(posting): post_entry writes one entry
|
|
31
|
+
|
|
32
|
+
2d5a6c9 2026-06-17 feat(errors): ValidationError and SettlementError
|
|
33
|
+
|
|
34
|
+
1c4f0b8 2026-06-10 chore: initial layout
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
Branch `main` is at `9f2c1ab`. Tags: `v0.9.0` on `9f2c1ab`, `v0.8.0` on
|
|
40
|
+
|
|
41
|
+
`5c0de41`.
|
|
42
|
+
|
|
43
|
+
'
|
|
44
|
+
README.md: '# svc-ledger
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Double-entry ledger service. Three handlers, one settings module, no globals.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
- Module map and the call order between modules: `docs/architecture.md`
|
|
51
|
+
|
|
52
|
+
- Operating procedure, escalation, and where the tunables live: `docs/runbook.md`
|
|
53
|
+
|
|
54
|
+
- Release history, newest first, with commit ids: `HISTORY.md`
|
|
55
|
+
|
|
56
|
+
- Patch files for selected commits: `patches/`
|
|
57
|
+
|
|
58
|
+
- Open incidents, including captured tracebacks: `issues/`
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
Handlers are resolved at import time from `src/ledger/registry.py`. Nothing else
|
|
62
|
+
|
|
63
|
+
in the service knows the handler names.
|
|
64
|
+
|
|
65
|
+
'
|
|
66
|
+
docs/architecture.md: "# Architecture\n\nThe three entry points are the handlers listed in `src/ledger/registry.py`.\n\
|
|
67
|
+
\n- `settle.py` drives a batch. It wraps `posting.post_entry` in\n `retry.with_retry`, and raises\
|
|
68
|
+
\ its own error when a batch is too large.\n- `posting.py` writes one entry. It validates first, via\n\
|
|
69
|
+
\ `validate.validate_entry`, and defines no exception of its own.\n- `report.py` renders a summary.\
|
|
70
|
+
\ It reads the registry and imports no handler.\n- `validate.py` is the only module that decides an\
|
|
71
|
+
\ entry is malformed.\n- `errors.py` defines every exception class in the service. No other module\n\
|
|
72
|
+
\ declares one.\n\nEvery tunable is a key in `src/ledger/config.py`; that module is the only one\n\
|
|
73
|
+
that touches `os.environ`. Modules ask `load_settings()` for values, so an\nattribute read on a `Settings`\
|
|
74
|
+
\ object is how you tell that a module consumes a\nconfig key.\n"
|
|
75
|
+
docs/runbook.md: '# Runbook
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Attempt counts, backoff intervals and batch sizes are **not written on this
|
|
79
|
+
|
|
80
|
+
page** and never will be. Every one of them is a key with its default in
|
|
81
|
+
|
|
82
|
+
`src/ledger/config.py` — read that file for the current value. Do not answer an
|
|
83
|
+
|
|
84
|
+
operational question about a number from this page; it will be stale.
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Escalation: three consecutive settlement failures page the on-call. Foreign
|
|
88
|
+
|
|
89
|
+
currency entries are converted, not rejected. A malformed entry is a caller bug
|
|
90
|
+
|
|
91
|
+
and is never retried past the wrapper''s own attempt budget.
|
|
92
|
+
|
|
93
|
+
'
|
|
94
|
+
issues/142-settlement-timeout.md: "# 142 — settlement aborts on a zero-amount entry\n\nReported by ops\
|
|
95
|
+
\ on 2026-08-02 against `main` (`9f2c1ab`). One entry in a batch\nof 40 carried `amount: 0`; the whole\
|
|
96
|
+
\ batch aborted after the wrapper exhausted\nits attempts. Captured traceback, verbatim from the worker\
|
|
97
|
+
\ log:\n\n```\nTraceback (most recent call last):\n File \"src/ledger/settle.py\", line 15, in settle_batch\n\
|
|
98
|
+
\ posted.append(with_retry(post_entry, entry))\n File \"src/ledger/retry.py\", line 17, in with_retry\n\
|
|
99
|
+
\ raise last\n File \"src/ledger/posting.py\", line 9, in post_entry\n validate_entry(entry)\n\
|
|
100
|
+
\ File \"src/ledger/validate.py\", line 13, in validate_entry\n raise ValidationError(f\"amount\
|
|
101
|
+
\ must be non-zero: {entry!r}\")\nledger.errors.ValidationError: amount must be non-zero: {'amount':\
|
|
102
|
+
\ 0, 'ccy': 'EUR'}\n```\n\nOps question: which change put this check in, and should a caller bug really\n\
|
|
103
|
+
burn the whole attempt budget? Runbook says a malformed entry is never retried\npast the wrapper's\
|
|
104
|
+
\ budget, so behaviour matches the runbook; the argument is\nabout whether the budget should apply\
|
|
105
|
+
\ at all.\n"
|
|
106
|
+
patches/0009-retry-budget.patch: "From 9f2c1ab5e2d84c1f0a7b6935cc41d0e2a8f37b41 Mon Sep 17 00:00:00\
|
|
107
|
+
\ 2001\nDate: Wed, 29 Jul 2026 09:14:02 +0000\nSubject: [PATCH] fix(retry): read retry_max_attempts\
|
|
108
|
+
\ from settings, not the\n constant\n\nThe wrapper had its own hard-coded attempt count, so raising\
|
|
109
|
+
\ the deployed\nbudget changed nothing. It now asks load_settings() like every other module.\n---\n\
|
|
110
|
+
\ src/ledger/retry.py | 8 +++++---\n 1 file changed, 5 insertions(+), 3 deletions(-)\n\ndiff --git\
|
|
111
|
+
\ a/src/ledger/retry.py b/src/ledger/retry.py\n--- a/src/ledger/retry.py\n+++ b/src/ledger/retry.py\n\
|
|
112
|
+
@@ -1,14 +1,15 @@\n-\"\"\"Retry wrapper. Attempts are fixed at the module constant below.\"\"\"\n\
|
|
113
|
+
+\"\"\"Retry wrapper. Reads its attempt count from settings, never from a constant.\"\"\"\n\n import\
|
|
114
|
+
\ time\n\n-_MAX_ATTEMPTS = 3\n+from ledger.config import load_settings\n\n\n def with_retry(fn, *args):\n\
|
|
115
|
+
+ settings = load_settings()\n last = None\n- for _attempt in range(_MAX_ATTEMPTS):\n+ \
|
|
116
|
+
\ for _attempt in range(settings.retry_max_attempts):\n try:\n return fn(*args)\n\
|
|
117
|
+
\ except Exception as exc:\n last = exc\n- time.sleep(0.25)\n+ \
|
|
118
|
+
\ time.sleep(settings.retry_backoff_ms / 1000)\n raise last\n--\n2.45.2\n"
|
|
119
|
+
src/ledger/__init__.py: '"""svc-ledger: a double-entry ledger service."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__version__ = "0.9.0"
|
|
123
|
+
|
|
124
|
+
'
|
|
125
|
+
src/ledger/config.py: "\"\"\"The only module in svc-ledger that reads os.environ.\n\nEvery tunable is\
|
|
126
|
+
\ a key in CONFIG_KEYS with its default beside it. docs/runbook.md\ndeliberately does not repeat these\
|
|
127
|
+
\ values: one place, one default.\n\"\"\"\n\nimport os\n\nCONFIG_KEYS = {\n \"retry_max_attempts\"\
|
|
128
|
+
: 5,\n \"retry_backoff_ms\": 250,\n \"settle_batch_size\": 200,\n \"posting_currency\": \"\
|
|
129
|
+
EUR\",\n \"report_top_n\": 10,\n}\n\n\nclass Settings:\n \"\"\"Attribute access over CONFIG_KEYS,\
|
|
130
|
+
\ environment first.\"\"\"\n\n def __init__(self, values):\n self.values = values\n\n \
|
|
131
|
+
\ def __getattr__(self, name):\n if name not in self.values:\n raise AttributeError(f\"\
|
|
132
|
+
unknown config key: {name}\")\n return self.values[name]\n\n\ndef load_settings():\n values\
|
|
133
|
+
\ = {}\n for key, default in CONFIG_KEYS.items():\n raw = os.environ.get(\"LEDGER_\" + key.upper())\n\
|
|
134
|
+
\ values[key] = type(default)(raw) if raw is not None else default\n return Settings(values)\n"
|
|
135
|
+
src/ledger/errors.py: "\"\"\"Exception hierarchy for svc-ledger. Every module raises from here.\"\"\"\
|
|
136
|
+
\n\n\nclass LedgerError(Exception):\n \"\"\"Base class. Nothing raises this directly.\"\"\"\n\n\
|
|
137
|
+
\nclass ValidationError(LedgerError):\n \"\"\"An entry failed validate_entry.\"\"\"\n\n\nclass\
|
|
138
|
+
\ SettlementError(LedgerError):\n \"\"\"A batch could not be settled.\"\"\"\n"
|
|
139
|
+
src/ledger/posting.py: "\"\"\"Post one entry to the ledger. Validates before writing; raises nothing\
|
|
140
|
+
\ itself.\"\"\"\n\nfrom ledger.config import load_settings\nfrom ledger.validate import validate_entry\n\
|
|
141
|
+
\n\ndef post_entry(entry):\n settings = load_settings()\n validate_entry(entry)\n if entry[\"\
|
|
142
|
+
ccy\"] != settings.posting_currency:\n return {\"status\": \"converted\", \"ccy\": settings.posting_currency}\n\
|
|
143
|
+
\ return {\"status\": \"posted\", \"ccy\": entry[\"ccy\"]}\n"
|
|
144
|
+
src/ledger/registry.py: "\"\"\"Handler registry. Resolved once at import time by the service entry point.\"\
|
|
145
|
+
\"\"\n\nHANDLERS = {\n \"settle\": \"ledger.settle:settle_batch\",\n \"post\": \"ledger.posting:post_entry\"\
|
|
146
|
+
,\n \"report\": \"ledger.report:build_report\",\n}\n"
|
|
147
|
+
src/ledger/report.py: "\"\"\"Per-handler summary. Reads the registry; imports no handler and no settings.\"\
|
|
148
|
+
\"\"\n\nfrom ledger.registry import HANDLERS\n\n\ndef build_report(rows):\n ranked = sorted(rows,\
|
|
149
|
+
\ key=lambda r: r[\"amount\"], reverse=True)\n # Left over from 6b1a903: this 10 was never moved\
|
|
150
|
+
\ into CONFIG_KEYS.\n return {\"handlers\": sorted(HANDLERS), \"top\": ranked[:10]}\n"
|
|
151
|
+
src/ledger/retry.py: "\"\"\"Retry wrapper. Reads its attempt count from settings, never from a constant.\"\
|
|
152
|
+
\"\"\n\nimport time\n\nfrom ledger.config import load_settings\n\n\ndef with_retry(fn, *args):\n \
|
|
153
|
+
\ settings = load_settings()\n last = None\n for _attempt in range(settings.retry_max_attempts):\n\
|
|
154
|
+
\ try:\n return fn(*args)\n except Exception as exc:\n last =\
|
|
155
|
+
\ exc\n time.sleep(settings.retry_backoff_ms / 1000)\n raise last\n"
|
|
156
|
+
src/ledger/settle.py: "\"\"\"Batch settlement. Drives posting through the retry wrapper.\"\"\"\n\nfrom\
|
|
157
|
+
\ ledger.config import load_settings\nfrom ledger.errors import SettlementError\nfrom ledger.posting\
|
|
158
|
+
\ import post_entry\nfrom ledger.retry import with_retry\n\n\ndef settle_batch(entries):\n settings\
|
|
159
|
+
\ = load_settings()\n if len(entries) > settings.settle_batch_size:\n raise SettlementError(f\"\
|
|
160
|
+
batch of {len(entries)} exceeds settle_batch_size\")\n posted = []\n for entry in entries:\n\
|
|
161
|
+
\ posted.append(with_retry(post_entry, entry))\n return posted\n"
|
|
162
|
+
src/ledger/validate.py: "\"\"\"Entry validation. The only module that decides an entry is malformed.\"\
|
|
163
|
+
\"\"\n\nfrom ledger.errors import ValidationError\n\nREQUIRED_FIELDS = (\"amount\", \"ccy\")\n\n\n\
|
|
164
|
+
def validate_entry(entry):\n for field in REQUIRED_FIELDS:\n if field not in entry:\n \
|
|
165
|
+
\ raise ValidationError(f\"missing field {field!r}: {entry!r}\")\n if entry[\"amount\"\
|
|
166
|
+
] == 0:\n raise ValidationError(f\"amount must be non-zero: {entry!r}\")\n return entry\n"
|
|
167
|
+
tests/test_posting.py: "import pytest\n\nfrom ledger.errors import ValidationError\nfrom ledger.posting\
|
|
168
|
+
\ import post_entry\n\n\ndef test_post_entry_rejects_zero_amount():\n with pytest.raises(ValidationError):\n\
|
|
169
|
+
\ post_entry({\"amount\": 0, \"ccy\": \"EUR\"})\n\n\ndef test_post_entry_converts_foreign_currency():\n\
|
|
170
|
+
\ assert post_entry({\"amount\": 10, \"ccy\": \"USD\"})[\"status\"] == \"converted\"\n"
|
|
171
|
+
tests/test_settle.py: "import pytest\n\nfrom ledger.errors import SettlementError\nfrom ledger.settle\
|
|
172
|
+
\ import settle_batch\n\n\ndef test_settle_batch_rejects_oversized_batch():\n with pytest.raises(SettlementError):\n\
|
|
173
|
+
\ settle_batch([{\"amount\": 1, \"ccy\": \"EUR\"}] * 201)\n"
|
|
174
|
+
prompt: 'Two questions about svc-ledger''s exceptions. (a) An entry with amount 0 reaches posting.post_entry
|
|
175
|
+
— name the exception class that leaves post_entry, and the file that DEFINES it. (b) Name the exception
|
|
176
|
+
class settle.settle_batch raises when a batch is too large, and the file that DEFINES it. For each,
|
|
177
|
+
read the module that raises and then the module that defines the class; do not answer from memory. Answer
|
|
178
|
+
with ONLY this JSON, nothing else: {"a_class": "<name>", "a_path": "<path>", "b_class": "<name>", "b_path":
|
|
179
|
+
"<path>"}'
|
|
180
|
+
scoring:
|
|
181
|
+
kind: json_equal
|
|
182
|
+
expected:
|
|
183
|
+
a_class: ValidationError
|
|
184
|
+
a_path: src/ledger/errors.py
|
|
185
|
+
b_class: SettlementError
|
|
186
|
+
b_path: src/ledger/errors.py
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# GENERATED by tools/devteam/build_tasks.py from assets/evals/devteam/manifest.yaml.
|
|
2
|
+
# Do not hand-edit: `build_tasks.py check` fails if you do. The task's selection
|
|
3
|
+
# rationale, reference walk and exclusion record live in the manifest.
|
|
4
|
+
name: dt-handler-map
|
|
5
|
+
family: dev-repo-code
|
|
6
|
+
tools:
|
|
7
|
+
- read_file
|
|
8
|
+
- list_files
|
|
9
|
+
workspace:
|
|
10
|
+
HISTORY.md: '# Release history
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
`git log --oneline --date=short --pretty=''%h %ad %s''`, newest first. Patch files
|
|
14
|
+
|
|
15
|
+
for selected commits are under `patches/`.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
9f2c1ab 2026-07-29 fix(retry): read retry_max_attempts from settings, not the constant
|
|
21
|
+
|
|
22
|
+
7d4e88c 2026-07-22 feat(settle): batch settlement with per-entry retry
|
|
23
|
+
|
|
24
|
+
6b1a903 2026-07-15 refactor(config): move every tunable into CONFIG_KEYS
|
|
25
|
+
|
|
26
|
+
5c0de41 2026-07-08 fix(validate): reject zero-amount entries
|
|
27
|
+
|
|
28
|
+
4a9bb27 2026-07-01 feat(report): render a per-handler summary
|
|
29
|
+
|
|
30
|
+
3e88f10 2026-06-24 feat(posting): post_entry writes one entry
|
|
31
|
+
|
|
32
|
+
2d5a6c9 2026-06-17 feat(errors): ValidationError and SettlementError
|
|
33
|
+
|
|
34
|
+
1c4f0b8 2026-06-10 chore: initial layout
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
Branch `main` is at `9f2c1ab`. Tags: `v0.9.0` on `9f2c1ab`, `v0.8.0` on
|
|
40
|
+
|
|
41
|
+
`5c0de41`.
|
|
42
|
+
|
|
43
|
+
'
|
|
44
|
+
README.md: '# svc-ledger
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Double-entry ledger service. Three handlers, one settings module, no globals.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
- Module map and the call order between modules: `docs/architecture.md`
|
|
51
|
+
|
|
52
|
+
- Operating procedure, escalation, and where the tunables live: `docs/runbook.md`
|
|
53
|
+
|
|
54
|
+
- Release history, newest first, with commit ids: `HISTORY.md`
|
|
55
|
+
|
|
56
|
+
- Patch files for selected commits: `patches/`
|
|
57
|
+
|
|
58
|
+
- Open incidents, including captured tracebacks: `issues/`
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
Handlers are resolved at import time from `src/ledger/registry.py`. Nothing else
|
|
62
|
+
|
|
63
|
+
in the service knows the handler names.
|
|
64
|
+
|
|
65
|
+
'
|
|
66
|
+
docs/architecture.md: "# Architecture\n\nThe three entry points are the handlers listed in `src/ledger/registry.py`.\n\
|
|
67
|
+
\n- `settle.py` drives a batch. It wraps `posting.post_entry` in\n `retry.with_retry`, and raises\
|
|
68
|
+
\ its own error when a batch is too large.\n- `posting.py` writes one entry. It validates first, via\n\
|
|
69
|
+
\ `validate.validate_entry`, and defines no exception of its own.\n- `report.py` renders a summary.\
|
|
70
|
+
\ It reads the registry and imports no handler.\n- `validate.py` is the only module that decides an\
|
|
71
|
+
\ entry is malformed.\n- `errors.py` defines every exception class in the service. No other module\n\
|
|
72
|
+
\ declares one.\n\nEvery tunable is a key in `src/ledger/config.py`; that module is the only one\n\
|
|
73
|
+
that touches `os.environ`. Modules ask `load_settings()` for values, so an\nattribute read on a `Settings`\
|
|
74
|
+
\ object is how you tell that a module consumes a\nconfig key.\n"
|
|
75
|
+
docs/runbook.md: '# Runbook
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Attempt counts, backoff intervals and batch sizes are **not written on this
|
|
79
|
+
|
|
80
|
+
page** and never will be. Every one of them is a key with its default in
|
|
81
|
+
|
|
82
|
+
`src/ledger/config.py` — read that file for the current value. Do not answer an
|
|
83
|
+
|
|
84
|
+
operational question about a number from this page; it will be stale.
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Escalation: three consecutive settlement failures page the on-call. Foreign
|
|
88
|
+
|
|
89
|
+
currency entries are converted, not rejected. A malformed entry is a caller bug
|
|
90
|
+
|
|
91
|
+
and is never retried past the wrapper''s own attempt budget.
|
|
92
|
+
|
|
93
|
+
'
|
|
94
|
+
issues/142-settlement-timeout.md: "# 142 — settlement aborts on a zero-amount entry\n\nReported by ops\
|
|
95
|
+
\ on 2026-08-02 against `main` (`9f2c1ab`). One entry in a batch\nof 40 carried `amount: 0`; the whole\
|
|
96
|
+
\ batch aborted after the wrapper exhausted\nits attempts. Captured traceback, verbatim from the worker\
|
|
97
|
+
\ log:\n\n```\nTraceback (most recent call last):\n File \"src/ledger/settle.py\", line 15, in settle_batch\n\
|
|
98
|
+
\ posted.append(with_retry(post_entry, entry))\n File \"src/ledger/retry.py\", line 17, in with_retry\n\
|
|
99
|
+
\ raise last\n File \"src/ledger/posting.py\", line 9, in post_entry\n validate_entry(entry)\n\
|
|
100
|
+
\ File \"src/ledger/validate.py\", line 13, in validate_entry\n raise ValidationError(f\"amount\
|
|
101
|
+
\ must be non-zero: {entry!r}\")\nledger.errors.ValidationError: amount must be non-zero: {'amount':\
|
|
102
|
+
\ 0, 'ccy': 'EUR'}\n```\n\nOps question: which change put this check in, and should a caller bug really\n\
|
|
103
|
+
burn the whole attempt budget? Runbook says a malformed entry is never retried\npast the wrapper's\
|
|
104
|
+
\ budget, so behaviour matches the runbook; the argument is\nabout whether the budget should apply\
|
|
105
|
+
\ at all.\n"
|
|
106
|
+
patches/0009-retry-budget.patch: "From 9f2c1ab5e2d84c1f0a7b6935cc41d0e2a8f37b41 Mon Sep 17 00:00:00\
|
|
107
|
+
\ 2001\nDate: Wed, 29 Jul 2026 09:14:02 +0000\nSubject: [PATCH] fix(retry): read retry_max_attempts\
|
|
108
|
+
\ from settings, not the\n constant\n\nThe wrapper had its own hard-coded attempt count, so raising\
|
|
109
|
+
\ the deployed\nbudget changed nothing. It now asks load_settings() like every other module.\n---\n\
|
|
110
|
+
\ src/ledger/retry.py | 8 +++++---\n 1 file changed, 5 insertions(+), 3 deletions(-)\n\ndiff --git\
|
|
111
|
+
\ a/src/ledger/retry.py b/src/ledger/retry.py\n--- a/src/ledger/retry.py\n+++ b/src/ledger/retry.py\n\
|
|
112
|
+
@@ -1,14 +1,15 @@\n-\"\"\"Retry wrapper. Attempts are fixed at the module constant below.\"\"\"\n\
|
|
113
|
+
+\"\"\"Retry wrapper. Reads its attempt count from settings, never from a constant.\"\"\"\n\n import\
|
|
114
|
+
\ time\n\n-_MAX_ATTEMPTS = 3\n+from ledger.config import load_settings\n\n\n def with_retry(fn, *args):\n\
|
|
115
|
+
+ settings = load_settings()\n last = None\n- for _attempt in range(_MAX_ATTEMPTS):\n+ \
|
|
116
|
+
\ for _attempt in range(settings.retry_max_attempts):\n try:\n return fn(*args)\n\
|
|
117
|
+
\ except Exception as exc:\n last = exc\n- time.sleep(0.25)\n+ \
|
|
118
|
+
\ time.sleep(settings.retry_backoff_ms / 1000)\n raise last\n--\n2.45.2\n"
|
|
119
|
+
src/ledger/__init__.py: '"""svc-ledger: a double-entry ledger service."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__version__ = "0.9.0"
|
|
123
|
+
|
|
124
|
+
'
|
|
125
|
+
src/ledger/config.py: "\"\"\"The only module in svc-ledger that reads os.environ.\n\nEvery tunable is\
|
|
126
|
+
\ a key in CONFIG_KEYS with its default beside it. docs/runbook.md\ndeliberately does not repeat these\
|
|
127
|
+
\ values: one place, one default.\n\"\"\"\n\nimport os\n\nCONFIG_KEYS = {\n \"retry_max_attempts\"\
|
|
128
|
+
: 5,\n \"retry_backoff_ms\": 250,\n \"settle_batch_size\": 200,\n \"posting_currency\": \"\
|
|
129
|
+
EUR\",\n \"report_top_n\": 10,\n}\n\n\nclass Settings:\n \"\"\"Attribute access over CONFIG_KEYS,\
|
|
130
|
+
\ environment first.\"\"\"\n\n def __init__(self, values):\n self.values = values\n\n \
|
|
131
|
+
\ def __getattr__(self, name):\n if name not in self.values:\n raise AttributeError(f\"\
|
|
132
|
+
unknown config key: {name}\")\n return self.values[name]\n\n\ndef load_settings():\n values\
|
|
133
|
+
\ = {}\n for key, default in CONFIG_KEYS.items():\n raw = os.environ.get(\"LEDGER_\" + key.upper())\n\
|
|
134
|
+
\ values[key] = type(default)(raw) if raw is not None else default\n return Settings(values)\n"
|
|
135
|
+
src/ledger/errors.py: "\"\"\"Exception hierarchy for svc-ledger. Every module raises from here.\"\"\"\
|
|
136
|
+
\n\n\nclass LedgerError(Exception):\n \"\"\"Base class. Nothing raises this directly.\"\"\"\n\n\
|
|
137
|
+
\nclass ValidationError(LedgerError):\n \"\"\"An entry failed validate_entry.\"\"\"\n\n\nclass\
|
|
138
|
+
\ SettlementError(LedgerError):\n \"\"\"A batch could not be settled.\"\"\"\n"
|
|
139
|
+
src/ledger/posting.py: "\"\"\"Post one entry to the ledger. Validates before writing; raises nothing\
|
|
140
|
+
\ itself.\"\"\"\n\nfrom ledger.config import load_settings\nfrom ledger.validate import validate_entry\n\
|
|
141
|
+
\n\ndef post_entry(entry):\n settings = load_settings()\n validate_entry(entry)\n if entry[\"\
|
|
142
|
+
ccy\"] != settings.posting_currency:\n return {\"status\": \"converted\", \"ccy\": settings.posting_currency}\n\
|
|
143
|
+
\ return {\"status\": \"posted\", \"ccy\": entry[\"ccy\"]}\n"
|
|
144
|
+
src/ledger/registry.py: "\"\"\"Handler registry. Resolved once at import time by the service entry point.\"\
|
|
145
|
+
\"\"\n\nHANDLERS = {\n \"settle\": \"ledger.settle:settle_batch\",\n \"post\": \"ledger.posting:post_entry\"\
|
|
146
|
+
,\n \"report\": \"ledger.report:build_report\",\n}\n"
|
|
147
|
+
src/ledger/report.py: "\"\"\"Per-handler summary. Reads the registry; imports no handler and no settings.\"\
|
|
148
|
+
\"\"\n\nfrom ledger.registry import HANDLERS\n\n\ndef build_report(rows):\n ranked = sorted(rows,\
|
|
149
|
+
\ key=lambda r: r[\"amount\"], reverse=True)\n # Left over from 6b1a903: this 10 was never moved\
|
|
150
|
+
\ into CONFIG_KEYS.\n return {\"handlers\": sorted(HANDLERS), \"top\": ranked[:10]}\n"
|
|
151
|
+
src/ledger/retry.py: "\"\"\"Retry wrapper. Reads its attempt count from settings, never from a constant.\"\
|
|
152
|
+
\"\"\n\nimport time\n\nfrom ledger.config import load_settings\n\n\ndef with_retry(fn, *args):\n \
|
|
153
|
+
\ settings = load_settings()\n last = None\n for _attempt in range(settings.retry_max_attempts):\n\
|
|
154
|
+
\ try:\n return fn(*args)\n except Exception as exc:\n last =\
|
|
155
|
+
\ exc\n time.sleep(settings.retry_backoff_ms / 1000)\n raise last\n"
|
|
156
|
+
src/ledger/settle.py: "\"\"\"Batch settlement. Drives posting through the retry wrapper.\"\"\"\n\nfrom\
|
|
157
|
+
\ ledger.config import load_settings\nfrom ledger.errors import SettlementError\nfrom ledger.posting\
|
|
158
|
+
\ import post_entry\nfrom ledger.retry import with_retry\n\n\ndef settle_batch(entries):\n settings\
|
|
159
|
+
\ = load_settings()\n if len(entries) > settings.settle_batch_size:\n raise SettlementError(f\"\
|
|
160
|
+
batch of {len(entries)} exceeds settle_batch_size\")\n posted = []\n for entry in entries:\n\
|
|
161
|
+
\ posted.append(with_retry(post_entry, entry))\n return posted\n"
|
|
162
|
+
src/ledger/validate.py: "\"\"\"Entry validation. The only module that decides an entry is malformed.\"\
|
|
163
|
+
\"\"\n\nfrom ledger.errors import ValidationError\n\nREQUIRED_FIELDS = (\"amount\", \"ccy\")\n\n\n\
|
|
164
|
+
def validate_entry(entry):\n for field in REQUIRED_FIELDS:\n if field not in entry:\n \
|
|
165
|
+
\ raise ValidationError(f\"missing field {field!r}: {entry!r}\")\n if entry[\"amount\"\
|
|
166
|
+
] == 0:\n raise ValidationError(f\"amount must be non-zero: {entry!r}\")\n return entry\n"
|
|
167
|
+
tests/test_posting.py: "import pytest\n\nfrom ledger.errors import ValidationError\nfrom ledger.posting\
|
|
168
|
+
\ import post_entry\n\n\ndef test_post_entry_rejects_zero_amount():\n with pytest.raises(ValidationError):\n\
|
|
169
|
+
\ post_entry({\"amount\": 0, \"ccy\": \"EUR\"})\n\n\ndef test_post_entry_converts_foreign_currency():\n\
|
|
170
|
+
\ assert post_entry({\"amount\": 10, \"ccy\": \"USD\"})[\"status\"] == \"converted\"\n"
|
|
171
|
+
tests/test_settle.py: "import pytest\n\nfrom ledger.errors import SettlementError\nfrom ledger.settle\
|
|
172
|
+
\ import settle_batch\n\n\ndef test_settle_batch_rejects_oversized_batch():\n with pytest.raises(SettlementError):\n\
|
|
173
|
+
\ settle_batch([{\"amount\": 1, \"ccy\": \"EUR\"}] * 201)\n"
|
|
174
|
+
prompt: 'For each of the three handlers registered in src/ledger/registry.py, report whether the file
|
|
175
|
+
that defines its entry function imports ledger.config. Read the registry, then read each of the three
|
|
176
|
+
files it points at. Do not answer from memory. Answer with ONLY this JSON, nothing else: {"settle":
|
|
177
|
+
<true|false>, "post": <true|false>, "report": <true|false>}'
|
|
178
|
+
scoring:
|
|
179
|
+
kind: json_equal
|
|
180
|
+
expected:
|
|
181
|
+
settle: true
|
|
182
|
+
post: true
|
|
183
|
+
report: false
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# GENERATED by tools/devteam/build_tasks.py from assets/evals/devteam/manifest.yaml.
|
|
2
|
+
# Do not hand-edit: `build_tasks.py check` fails if you do. The task's selection
|
|
3
|
+
# rationale, reference walk and exclusion record live in the manifest.
|
|
4
|
+
name: dt-patch-before-after
|
|
5
|
+
family: dev-repo-history
|
|
6
|
+
tools:
|
|
7
|
+
- read_file
|
|
8
|
+
- list_files
|
|
9
|
+
workspace:
|
|
10
|
+
HISTORY.md: '# Release history
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
`git log --oneline --date=short --pretty=''%h %ad %s''`, newest first. Patch files
|
|
14
|
+
|
|
15
|
+
for selected commits are under `patches/`.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
9f2c1ab 2026-07-29 fix(retry): read retry_max_attempts from settings, not the constant
|
|
21
|
+
|
|
22
|
+
7d4e88c 2026-07-22 feat(settle): batch settlement with per-entry retry
|
|
23
|
+
|
|
24
|
+
6b1a903 2026-07-15 refactor(config): move every tunable into CONFIG_KEYS
|
|
25
|
+
|
|
26
|
+
5c0de41 2026-07-08 fix(validate): reject zero-amount entries
|
|
27
|
+
|
|
28
|
+
4a9bb27 2026-07-01 feat(report): render a per-handler summary
|
|
29
|
+
|
|
30
|
+
3e88f10 2026-06-24 feat(posting): post_entry writes one entry
|
|
31
|
+
|
|
32
|
+
2d5a6c9 2026-06-17 feat(errors): ValidationError and SettlementError
|
|
33
|
+
|
|
34
|
+
1c4f0b8 2026-06-10 chore: initial layout
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
Branch `main` is at `9f2c1ab`. Tags: `v0.9.0` on `9f2c1ab`, `v0.8.0` on
|
|
40
|
+
|
|
41
|
+
`5c0de41`.
|
|
42
|
+
|
|
43
|
+
'
|
|
44
|
+
README.md: '# svc-ledger
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Double-entry ledger service. Three handlers, one settings module, no globals.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
- Module map and the call order between modules: `docs/architecture.md`
|
|
51
|
+
|
|
52
|
+
- Operating procedure, escalation, and where the tunables live: `docs/runbook.md`
|
|
53
|
+
|
|
54
|
+
- Release history, newest first, with commit ids: `HISTORY.md`
|
|
55
|
+
|
|
56
|
+
- Patch files for selected commits: `patches/`
|
|
57
|
+
|
|
58
|
+
- Open incidents, including captured tracebacks: `issues/`
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
Handlers are resolved at import time from `src/ledger/registry.py`. Nothing else
|
|
62
|
+
|
|
63
|
+
in the service knows the handler names.
|
|
64
|
+
|
|
65
|
+
'
|
|
66
|
+
docs/architecture.md: "# Architecture\n\nThe three entry points are the handlers listed in `src/ledger/registry.py`.\n\
|
|
67
|
+
\n- `settle.py` drives a batch. It wraps `posting.post_entry` in\n `retry.with_retry`, and raises\
|
|
68
|
+
\ its own error when a batch is too large.\n- `posting.py` writes one entry. It validates first, via\n\
|
|
69
|
+
\ `validate.validate_entry`, and defines no exception of its own.\n- `report.py` renders a summary.\
|
|
70
|
+
\ It reads the registry and imports no handler.\n- `validate.py` is the only module that decides an\
|
|
71
|
+
\ entry is malformed.\n- `errors.py` defines every exception class in the service. No other module\n\
|
|
72
|
+
\ declares one.\n\nEvery tunable is a key in `src/ledger/config.py`; that module is the only one\n\
|
|
73
|
+
that touches `os.environ`. Modules ask `load_settings()` for values, so an\nattribute read on a `Settings`\
|
|
74
|
+
\ object is how you tell that a module consumes a\nconfig key.\n"
|
|
75
|
+
docs/runbook.md: '# Runbook
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Attempt counts, backoff intervals and batch sizes are **not written on this
|
|
79
|
+
|
|
80
|
+
page** and never will be. Every one of them is a key with its default in
|
|
81
|
+
|
|
82
|
+
`src/ledger/config.py` — read that file for the current value. Do not answer an
|
|
83
|
+
|
|
84
|
+
operational question about a number from this page; it will be stale.
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Escalation: three consecutive settlement failures page the on-call. Foreign
|
|
88
|
+
|
|
89
|
+
currency entries are converted, not rejected. A malformed entry is a caller bug
|
|
90
|
+
|
|
91
|
+
and is never retried past the wrapper''s own attempt budget.
|
|
92
|
+
|
|
93
|
+
'
|
|
94
|
+
issues/142-settlement-timeout.md: "# 142 — settlement aborts on a zero-amount entry\n\nReported by ops\
|
|
95
|
+
\ on 2026-08-02 against `main` (`9f2c1ab`). One entry in a batch\nof 40 carried `amount: 0`; the whole\
|
|
96
|
+
\ batch aborted after the wrapper exhausted\nits attempts. Captured traceback, verbatim from the worker\
|
|
97
|
+
\ log:\n\n```\nTraceback (most recent call last):\n File \"src/ledger/settle.py\", line 15, in settle_batch\n\
|
|
98
|
+
\ posted.append(with_retry(post_entry, entry))\n File \"src/ledger/retry.py\", line 17, in with_retry\n\
|
|
99
|
+
\ raise last\n File \"src/ledger/posting.py\", line 9, in post_entry\n validate_entry(entry)\n\
|
|
100
|
+
\ File \"src/ledger/validate.py\", line 13, in validate_entry\n raise ValidationError(f\"amount\
|
|
101
|
+
\ must be non-zero: {entry!r}\")\nledger.errors.ValidationError: amount must be non-zero: {'amount':\
|
|
102
|
+
\ 0, 'ccy': 'EUR'}\n```\n\nOps question: which change put this check in, and should a caller bug really\n\
|
|
103
|
+
burn the whole attempt budget? Runbook says a malformed entry is never retried\npast the wrapper's\
|
|
104
|
+
\ budget, so behaviour matches the runbook; the argument is\nabout whether the budget should apply\
|
|
105
|
+
\ at all.\n"
|
|
106
|
+
patches/0009-retry-budget.patch: "From 9f2c1ab5e2d84c1f0a7b6935cc41d0e2a8f37b41 Mon Sep 17 00:00:00\
|
|
107
|
+
\ 2001\nDate: Wed, 29 Jul 2026 09:14:02 +0000\nSubject: [PATCH] fix(retry): read retry_max_attempts\
|
|
108
|
+
\ from settings, not the\n constant\n\nThe wrapper had its own hard-coded attempt count, so raising\
|
|
109
|
+
\ the deployed\nbudget changed nothing. It now asks load_settings() like every other module.\n---\n\
|
|
110
|
+
\ src/ledger/retry.py | 8 +++++---\n 1 file changed, 5 insertions(+), 3 deletions(-)\n\ndiff --git\
|
|
111
|
+
\ a/src/ledger/retry.py b/src/ledger/retry.py\n--- a/src/ledger/retry.py\n+++ b/src/ledger/retry.py\n\
|
|
112
|
+
@@ -1,14 +1,15 @@\n-\"\"\"Retry wrapper. Attempts are fixed at the module constant below.\"\"\"\n\
|
|
113
|
+
+\"\"\"Retry wrapper. Reads its attempt count from settings, never from a constant.\"\"\"\n\n import\
|
|
114
|
+
\ time\n\n-_MAX_ATTEMPTS = 3\n+from ledger.config import load_settings\n\n\n def with_retry(fn, *args):\n\
|
|
115
|
+
+ settings = load_settings()\n last = None\n- for _attempt in range(_MAX_ATTEMPTS):\n+ \
|
|
116
|
+
\ for _attempt in range(settings.retry_max_attempts):\n try:\n return fn(*args)\n\
|
|
117
|
+
\ except Exception as exc:\n last = exc\n- time.sleep(0.25)\n+ \
|
|
118
|
+
\ time.sleep(settings.retry_backoff_ms / 1000)\n raise last\n--\n2.45.2\n"
|
|
119
|
+
src/ledger/__init__.py: '"""svc-ledger: a double-entry ledger service."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__version__ = "0.9.0"
|
|
123
|
+
|
|
124
|
+
'
|
|
125
|
+
src/ledger/config.py: "\"\"\"The only module in svc-ledger that reads os.environ.\n\nEvery tunable is\
|
|
126
|
+
\ a key in CONFIG_KEYS with its default beside it. docs/runbook.md\ndeliberately does not repeat these\
|
|
127
|
+
\ values: one place, one default.\n\"\"\"\n\nimport os\n\nCONFIG_KEYS = {\n \"retry_max_attempts\"\
|
|
128
|
+
: 5,\n \"retry_backoff_ms\": 250,\n \"settle_batch_size\": 200,\n \"posting_currency\": \"\
|
|
129
|
+
EUR\",\n \"report_top_n\": 10,\n}\n\n\nclass Settings:\n \"\"\"Attribute access over CONFIG_KEYS,\
|
|
130
|
+
\ environment first.\"\"\"\n\n def __init__(self, values):\n self.values = values\n\n \
|
|
131
|
+
\ def __getattr__(self, name):\n if name not in self.values:\n raise AttributeError(f\"\
|
|
132
|
+
unknown config key: {name}\")\n return self.values[name]\n\n\ndef load_settings():\n values\
|
|
133
|
+
\ = {}\n for key, default in CONFIG_KEYS.items():\n raw = os.environ.get(\"LEDGER_\" + key.upper())\n\
|
|
134
|
+
\ values[key] = type(default)(raw) if raw is not None else default\n return Settings(values)\n"
|
|
135
|
+
src/ledger/errors.py: "\"\"\"Exception hierarchy for svc-ledger. Every module raises from here.\"\"\"\
|
|
136
|
+
\n\n\nclass LedgerError(Exception):\n \"\"\"Base class. Nothing raises this directly.\"\"\"\n\n\
|
|
137
|
+
\nclass ValidationError(LedgerError):\n \"\"\"An entry failed validate_entry.\"\"\"\n\n\nclass\
|
|
138
|
+
\ SettlementError(LedgerError):\n \"\"\"A batch could not be settled.\"\"\"\n"
|
|
139
|
+
src/ledger/posting.py: "\"\"\"Post one entry to the ledger. Validates before writing; raises nothing\
|
|
140
|
+
\ itself.\"\"\"\n\nfrom ledger.config import load_settings\nfrom ledger.validate import validate_entry\n\
|
|
141
|
+
\n\ndef post_entry(entry):\n settings = load_settings()\n validate_entry(entry)\n if entry[\"\
|
|
142
|
+
ccy\"] != settings.posting_currency:\n return {\"status\": \"converted\", \"ccy\": settings.posting_currency}\n\
|
|
143
|
+
\ return {\"status\": \"posted\", \"ccy\": entry[\"ccy\"]}\n"
|
|
144
|
+
src/ledger/registry.py: "\"\"\"Handler registry. Resolved once at import time by the service entry point.\"\
|
|
145
|
+
\"\"\n\nHANDLERS = {\n \"settle\": \"ledger.settle:settle_batch\",\n \"post\": \"ledger.posting:post_entry\"\
|
|
146
|
+
,\n \"report\": \"ledger.report:build_report\",\n}\n"
|
|
147
|
+
src/ledger/report.py: "\"\"\"Per-handler summary. Reads the registry; imports no handler and no settings.\"\
|
|
148
|
+
\"\"\n\nfrom ledger.registry import HANDLERS\n\n\ndef build_report(rows):\n ranked = sorted(rows,\
|
|
149
|
+
\ key=lambda r: r[\"amount\"], reverse=True)\n # Left over from 6b1a903: this 10 was never moved\
|
|
150
|
+
\ into CONFIG_KEYS.\n return {\"handlers\": sorted(HANDLERS), \"top\": ranked[:10]}\n"
|
|
151
|
+
src/ledger/retry.py: "\"\"\"Retry wrapper. Reads its attempt count from settings, never from a constant.\"\
|
|
152
|
+
\"\"\n\nimport time\n\nfrom ledger.config import load_settings\n\n\ndef with_retry(fn, *args):\n \
|
|
153
|
+
\ settings = load_settings()\n last = None\n for _attempt in range(settings.retry_max_attempts):\n\
|
|
154
|
+
\ try:\n return fn(*args)\n except Exception as exc:\n last =\
|
|
155
|
+
\ exc\n time.sleep(settings.retry_backoff_ms / 1000)\n raise last\n"
|
|
156
|
+
src/ledger/settle.py: "\"\"\"Batch settlement. Drives posting through the retry wrapper.\"\"\"\n\nfrom\
|
|
157
|
+
\ ledger.config import load_settings\nfrom ledger.errors import SettlementError\nfrom ledger.posting\
|
|
158
|
+
\ import post_entry\nfrom ledger.retry import with_retry\n\n\ndef settle_batch(entries):\n settings\
|
|
159
|
+
\ = load_settings()\n if len(entries) > settings.settle_batch_size:\n raise SettlementError(f\"\
|
|
160
|
+
batch of {len(entries)} exceeds settle_batch_size\")\n posted = []\n for entry in entries:\n\
|
|
161
|
+
\ posted.append(with_retry(post_entry, entry))\n return posted\n"
|
|
162
|
+
src/ledger/validate.py: "\"\"\"Entry validation. The only module that decides an entry is malformed.\"\
|
|
163
|
+
\"\"\n\nfrom ledger.errors import ValidationError\n\nREQUIRED_FIELDS = (\"amount\", \"ccy\")\n\n\n\
|
|
164
|
+
def validate_entry(entry):\n for field in REQUIRED_FIELDS:\n if field not in entry:\n \
|
|
165
|
+
\ raise ValidationError(f\"missing field {field!r}: {entry!r}\")\n if entry[\"amount\"\
|
|
166
|
+
] == 0:\n raise ValidationError(f\"amount must be non-zero: {entry!r}\")\n return entry\n"
|
|
167
|
+
tests/test_posting.py: "import pytest\n\nfrom ledger.errors import ValidationError\nfrom ledger.posting\
|
|
168
|
+
\ import post_entry\n\n\ndef test_post_entry_rejects_zero_amount():\n with pytest.raises(ValidationError):\n\
|
|
169
|
+
\ post_entry({\"amount\": 0, \"ccy\": \"EUR\"})\n\n\ndef test_post_entry_converts_foreign_currency():\n\
|
|
170
|
+
\ assert post_entry({\"amount\": 10, \"ccy\": \"USD\"})[\"status\"] == \"converted\"\n"
|
|
171
|
+
tests/test_settle.py: "import pytest\n\nfrom ledger.errors import SettlementError\nfrom ledger.settle\
|
|
172
|
+
\ import settle_batch\n\n\ndef test_settle_batch_rejects_oversized_batch():\n with pytest.raises(SettlementError):\n\
|
|
173
|
+
\ settle_batch([{\"amount\": 1, \"ccy\": \"EUR\"}] * 201)\n"
|
|
174
|
+
prompt: 'patches/0009-retry-budget.patch changed how src/ledger/retry.py obtains its attempt count. Report
|
|
175
|
+
the attempt count the code used BEFORE that patch, and the attempt count it uses at HEAD. Read the patch,
|
|
176
|
+
then the file at HEAD, then whatever that file reads its value from. Do not answer from memory. Answer
|
|
177
|
+
with ONLY this JSON, nothing else: {"before": <number>, "head": <number>}'
|
|
178
|
+
scoring:
|
|
179
|
+
kind: json_equal
|
|
180
|
+
expected:
|
|
181
|
+
before: 3
|
|
182
|
+
head: 5
|