wealthbraid 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.
- wealthbraid/__init__.py +7 -0
- wealthbraid/__main__.py +8 -0
- wealthbraid/book/__init__.py +9 -0
- wealthbraid/book/book.py +397 -0
- wealthbraid/book/config.py +333 -0
- wealthbraid/book/schema.py +295 -0
- wealthbraid/book/state.py +753 -0
- wealthbraid/book/verify.py +181 -0
- wealthbraid/cli/__init__.py +1 -0
- wealthbraid/cli/commands_analysis.py +309 -0
- wealthbraid/cli/commands_book.py +236 -0
- wealthbraid/cli/commands_ledger.py +407 -0
- wealthbraid/cli/commands_ops.py +219 -0
- wealthbraid/cli/commands_statements.py +279 -0
- wealthbraid/cli/common.py +390 -0
- wealthbraid/cli/main.py +99 -0
- wealthbraid/engine/__init__.py +39 -0
- wealthbraid/engine/account.py +150 -0
- wealthbraid/engine/balancing.py +158 -0
- wealthbraid/engine/errors.py +53 -0
- wealthbraid/engine/inventory.py +151 -0
- wealthbraid/engine/ledger.py +198 -0
- wealthbraid/engine/money.py +218 -0
- wealthbraid/engine/prices.py +230 -0
- wealthbraid/engine/transaction.py +107 -0
- wealthbraid/errors.py +65 -0
- wealthbraid/services/__init__.py +6 -0
- wealthbraid/services/categorize.py +241 -0
- wealthbraid/services/explain.py +225 -0
- wealthbraid/services/importing.py +285 -0
- wealthbraid/services/reconcile.py +215 -0
- wealthbraid/services/reports.py +301 -0
- wealthbraid/services/review.py +78 -0
- wealthbraid/services/scenarios.py +283 -0
- wealthbraid/store/__init__.py +8 -0
- wealthbraid/store/records.py +243 -0
- wealthbraid/store/store.py +487 -0
- wealthbraid/utils/__init__.py +7 -0
- wealthbraid/utils/log.py +70 -0
- wealthbraid/version.py +11 -0
- wealthbraid/web/__init__.py +1 -0
- wealthbraid/web/app.py +418 -0
- wealthbraid/web/static/VENDOR.json +13 -0
- wealthbraid/web/static/app.css +609 -0
- wealthbraid/web/static/htmx.min.js +1 -0
- wealthbraid/web/templates/_macros.html +26 -0
- wealthbraid/web/templates/_operation_card.html +64 -0
- wealthbraid/web/templates/base.html +35 -0
- wealthbraid/web/templates/entries.html +37 -0
- wealthbraid/web/templates/error.html +7 -0
- wealthbraid/web/templates/lines.html +36 -0
- wealthbraid/web/templates/operation.html +13 -0
- wealthbraid/web/templates/operations.html +36 -0
- wealthbraid/web/templates/overview.html +53 -0
- wealthbraid/web/templates/reconciliations.html +30 -0
- wealthbraid/web/templates/reports.html +71 -0
- wealthbraid/web/templates/review.html +25 -0
- wealthbraid/web/templates/trace.html +53 -0
- wealthbraid/web/templates/verify.html +21 -0
- wealthbraid-0.1.0.dist-info/METADATA +133 -0
- wealthbraid-0.1.0.dist-info/RECORD +64 -0
- wealthbraid-0.1.0.dist-info/WHEEL +4 -0
- wealthbraid-0.1.0.dist-info/entry_points.txt +2 -0
- wealthbraid-0.1.0.dist-info/licenses/LICENSE +26 -0
wealthbraid/__init__.py
ADDED
wealthbraid/__main__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Books: settings, the projection of records into state, and the operation workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from wealthbraid.book.book import Book
|
|
6
|
+
from wealthbraid.book.config import BookConfig, find_book, init_book, load_config
|
|
7
|
+
from wealthbraid.book.state import BookState, Issue, OperationState
|
|
8
|
+
|
|
9
|
+
__all__ = ["Book", "BookConfig", "BookState", "Issue", "OperationState", "find_book", "init_book", "load_config"]
|
wealthbraid/book/book.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""The :class:`Book` facade: the only way records enter a book.
|
|
2
|
+
|
|
3
|
+
Every mutation is an *operation*. An operation records who proposed it, the
|
|
4
|
+
inputs and evidence it used, a reasoning summary, a confidence, and the exact
|
|
5
|
+
records it would add. A proposal is validated against the current book before it
|
|
6
|
+
is stored, so an agent learns immediately whether its changes could apply.
|
|
7
|
+
|
|
8
|
+
An operation's changes are written only after a decision:
|
|
9
|
+
|
|
10
|
+
* a human approves or rejects it (``decide``), or
|
|
11
|
+
* policy auto-approves it, which is allowed only when every change is
|
|
12
|
+
non-sensitive (evidence, notes) and the book allows it.
|
|
13
|
+
|
|
14
|
+
Approval re-validates against the book as it is *now*; if the book moved on and
|
|
15
|
+
the changes no longer apply, nothing is written. Agents can never approve.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import datetime as dt
|
|
21
|
+
import mimetypes
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
28
|
+
|
|
29
|
+
from wealthbraid.book.config import BookConfig, find_book, load_config
|
|
30
|
+
from wealthbraid.book.schema import (
|
|
31
|
+
ChangeData,
|
|
32
|
+
DecisionData,
|
|
33
|
+
EvidenceData,
|
|
34
|
+
OperationData,
|
|
35
|
+
dump_data,
|
|
36
|
+
format_validation_error,
|
|
37
|
+
parse_data,
|
|
38
|
+
)
|
|
39
|
+
from wealthbraid.book.state import (
|
|
40
|
+
HUMAN_PREFIX,
|
|
41
|
+
NON_SENSITIVE_KINDS,
|
|
42
|
+
POLICY_ACTOR,
|
|
43
|
+
BookState,
|
|
44
|
+
OperationState,
|
|
45
|
+
has_refs,
|
|
46
|
+
resolve_refs,
|
|
47
|
+
)
|
|
48
|
+
from wealthbraid.errors import ConflictError, IntegrityError, NotFoundError, PolicyError, ValidationError
|
|
49
|
+
from wealthbraid.store.records import Record, RecordKind
|
|
50
|
+
from wealthbraid.store.store import PendingAppend, RecordStore, format_timestamp, utc_now
|
|
51
|
+
|
|
52
|
+
_ACTOR_RE = re.compile(r"\A(human|agent):[A-Za-z0-9._-]+\Z")
|
|
53
|
+
_DRY_RUN_APPROVER = "human:dry-run"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_actor(actor: str) -> str:
|
|
57
|
+
"""Validate a caller-supplied actor string.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
actor: The actor, ``human:<name>`` or ``agent:<name>``.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The actor unchanged.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
PolicyError: If the actor is malformed or claims a reserved ``system:`` identity.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
if not _ACTOR_RE.match(actor):
|
|
70
|
+
raise PolicyError(f"invalid actor {actor!r}; use human:<name> or agent:<name>")
|
|
71
|
+
return actor
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Book:
|
|
75
|
+
"""A wealthbraid book on disk."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, root: Path, *, clock: Callable[[], dt.datetime] = utc_now) -> None:
|
|
78
|
+
"""Open a book directory.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
root: The book directory (must contain ``wealthbraid.toml``).
|
|
82
|
+
clock: Source of write timestamps; injectable for deterministic tests.
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
self.root = Path(root)
|
|
86
|
+
self.config: BookConfig = load_config(self.root)
|
|
87
|
+
self.store = RecordStore(self.root, clock=clock)
|
|
88
|
+
self._clock = clock
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def discover(cls, explicit: Path | None = None, **kwargs: Any) -> Book:
|
|
92
|
+
"""Open the book found by :func:`~wealthbraid.book.config.find_book`.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
explicit: An explicit book path, if given.
|
|
96
|
+
**kwargs: Passed to :class:`Book`.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The opened book.
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
return cls(find_book(explicit=explicit), **kwargs)
|
|
103
|
+
|
|
104
|
+
# -- reading ------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def state(self, *, at: str | None = None) -> BookState:
|
|
107
|
+
"""Project the book's records into state.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
at: Stop after this record id, reproducing the book as it was then.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The :class:`BookState`.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
NotFoundError: If ``at`` is not a record in the book.
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
state = BookState()
|
|
120
|
+
for record in self.store.iter_records():
|
|
121
|
+
state.apply(record)
|
|
122
|
+
if record.id == at:
|
|
123
|
+
return state
|
|
124
|
+
if at is not None:
|
|
125
|
+
raise NotFoundError(f"record not found: {at}")
|
|
126
|
+
return state
|
|
127
|
+
|
|
128
|
+
# -- operations ---------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def propose( # noqa: PLR0913 - keyword-only; mirrors the operation record's fields
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
actor: str,
|
|
134
|
+
tool: str,
|
|
135
|
+
summary: str,
|
|
136
|
+
changes: Sequence[Mapping[str, Any]],
|
|
137
|
+
reasoning: str,
|
|
138
|
+
confidence: float,
|
|
139
|
+
evidence: Sequence[str] = (),
|
|
140
|
+
inputs: Mapping[str, Any] | None = None,
|
|
141
|
+
approve: bool = False,
|
|
142
|
+
note: str | None = None,
|
|
143
|
+
) -> OperationState:
|
|
144
|
+
"""Record an operation, applying it at once when allowed.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
actor: The proposer (``human:<name>`` or ``agent:<name>``).
|
|
148
|
+
tool: The operation name, e.g. ``"categorize"``.
|
|
149
|
+
summary: A one-line description of the proposal.
|
|
150
|
+
changes: Proposed records as ``{"kind", "data", "rationale"?}`` mappings.
|
|
151
|
+
reasoning: Why these changes are proposed.
|
|
152
|
+
confidence: The proposer's confidence in ``[0, 1]``.
|
|
153
|
+
evidence: Evidence record ids supporting the proposal.
|
|
154
|
+
inputs: The parameters and settings the operation used.
|
|
155
|
+
approve: Approve immediately; only a human proposer may do this.
|
|
156
|
+
note: The approval note, when ``approve`` is set.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
The stored operation with its resulting status.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
PolicyError: If a non-human asks to approve.
|
|
163
|
+
ValidationError: If the proposal is malformed or its changes cannot apply.
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
check_actor(actor)
|
|
167
|
+
if approve and not actor.startswith(HUMAN_PREFIX):
|
|
168
|
+
raise PolicyError(f"{actor} cannot approve operations; only a human can")
|
|
169
|
+
operation = self._build_operation(
|
|
170
|
+
tool=tool,
|
|
171
|
+
summary=summary,
|
|
172
|
+
changes=changes,
|
|
173
|
+
reasoning=reasoning,
|
|
174
|
+
confidence=confidence,
|
|
175
|
+
evidence=evidence,
|
|
176
|
+
inputs=inputs,
|
|
177
|
+
)
|
|
178
|
+
with self.store.lock():
|
|
179
|
+
state = self._writable_state()
|
|
180
|
+
operation_data = dump_data(operation.model_copy(update={"base": state.head.id if state.head else None}))
|
|
181
|
+
sensitive = any(RecordKind(change.kind) not in NON_SENSITIVE_KINDS for change in operation.changes)
|
|
182
|
+
if approve:
|
|
183
|
+
decider = actor
|
|
184
|
+
elif self.config.auto_apply and not sensitive:
|
|
185
|
+
decider = POLICY_ACTOR
|
|
186
|
+
else:
|
|
187
|
+
decider = None
|
|
188
|
+
timestamp = format_timestamp(self._clock())
|
|
189
|
+
|
|
190
|
+
staged = self.store.begin(recorded_at=timestamp)
|
|
191
|
+
op_record = staged.add(RecordKind.OPERATION, operation_data, actor=actor)
|
|
192
|
+
self._stage_decision(
|
|
193
|
+
staged,
|
|
194
|
+
op_record=op_record,
|
|
195
|
+
operation=operation,
|
|
196
|
+
actor=decider or _DRY_RUN_APPROVER,
|
|
197
|
+
verdict="approve",
|
|
198
|
+
note=note,
|
|
199
|
+
)
|
|
200
|
+
self._check(state, staged.records, "proposal cannot be applied")
|
|
201
|
+
|
|
202
|
+
if decider is None:
|
|
203
|
+
final = self.store.begin(recorded_at=timestamp)
|
|
204
|
+
final.add(RecordKind.OPERATION, operation_data, actor=actor)
|
|
205
|
+
final.commit()
|
|
206
|
+
else:
|
|
207
|
+
staged.commit()
|
|
208
|
+
return self._operation(op_record.id)
|
|
209
|
+
|
|
210
|
+
def decide(self, operation_id: str, *, actor: str, verdict: str, note: str | None = None) -> OperationState:
|
|
211
|
+
"""Approve or reject a pending operation.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
operation_id: The operation id.
|
|
215
|
+
actor: The deciding human.
|
|
216
|
+
verdict: ``"approve"`` or ``"reject"``.
|
|
217
|
+
note: An optional note explaining the decision.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
The operation with its new status.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
PolicyError: If the actor is not a human.
|
|
224
|
+
NotFoundError: If the operation does not exist.
|
|
225
|
+
ConflictError: If the operation was already decided.
|
|
226
|
+
ValidationError: If approved changes no longer apply to the book.
|
|
227
|
+
|
|
228
|
+
"""
|
|
229
|
+
check_actor(actor)
|
|
230
|
+
if not actor.startswith(HUMAN_PREFIX):
|
|
231
|
+
raise PolicyError(f"{actor} cannot decide operations; only a human can")
|
|
232
|
+
if verdict not in ("approve", "reject"):
|
|
233
|
+
raise ValidationError(f"verdict must be 'approve' or 'reject', not {verdict!r}")
|
|
234
|
+
with self.store.lock():
|
|
235
|
+
state = self._writable_state()
|
|
236
|
+
operation = state.operations.get(operation_id)
|
|
237
|
+
if operation is None:
|
|
238
|
+
raise NotFoundError(f"operation not found: {operation_id}")
|
|
239
|
+
if operation.status != "pending":
|
|
240
|
+
raise ConflictError(f"operation {operation_id} is already {operation.status}")
|
|
241
|
+
staged = self.store.begin()
|
|
242
|
+
self._stage_decision(
|
|
243
|
+
staged, op_record=operation.record, operation=operation.data, actor=actor, verdict=verdict, note=note
|
|
244
|
+
)
|
|
245
|
+
self._check(state, staged.records, "cannot approve: the changes no longer apply")
|
|
246
|
+
staged.commit()
|
|
247
|
+
return self._operation(operation_id)
|
|
248
|
+
|
|
249
|
+
def add_evidence(
|
|
250
|
+
self,
|
|
251
|
+
content: bytes,
|
|
252
|
+
*,
|
|
253
|
+
filename: str,
|
|
254
|
+
actor: str,
|
|
255
|
+
source: str | None = None,
|
|
256
|
+
description: str | None = None,
|
|
257
|
+
) -> tuple[str, OperationState | None]:
|
|
258
|
+
"""Store a source document and record it as evidence.
|
|
259
|
+
|
|
260
|
+
Adding identical bytes again returns the existing evidence record.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
content: The document bytes.
|
|
264
|
+
filename: The original file name.
|
|
265
|
+
actor: Who is adding the document.
|
|
266
|
+
source: Where it came from, e.g. a bank name.
|
|
267
|
+
description: A free-text description.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
The evidence record id and the operation that created it (``None`` if it already existed).
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ConflictError: If the book requires approval even for evidence.
|
|
274
|
+
|
|
275
|
+
"""
|
|
276
|
+
check_actor(actor)
|
|
277
|
+
digest = self.store.put_evidence(content)
|
|
278
|
+
existing = self.state().evidence_by_sha.get(digest)
|
|
279
|
+
if existing is not None:
|
|
280
|
+
return existing, None
|
|
281
|
+
data = EvidenceData(
|
|
282
|
+
sha256=digest,
|
|
283
|
+
filename=Path(filename).name,
|
|
284
|
+
media_type=mimetypes.guess_type(filename)[0] or "application/octet-stream",
|
|
285
|
+
size=len(content),
|
|
286
|
+
source=source,
|
|
287
|
+
description=description,
|
|
288
|
+
)
|
|
289
|
+
operation = self.propose(
|
|
290
|
+
actor=actor,
|
|
291
|
+
tool="evidence.add",
|
|
292
|
+
summary=f"Add evidence {data.filename}",
|
|
293
|
+
changes=[{"kind": "evidence", "data": dump_data(data)}],
|
|
294
|
+
reasoning="Store the source document so later records can cite it.",
|
|
295
|
+
confidence=1.0,
|
|
296
|
+
inputs={"filename": data.filename},
|
|
297
|
+
)
|
|
298
|
+
if operation.status != "applied":
|
|
299
|
+
raise ConflictError(
|
|
300
|
+
f"evidence operation {operation.id} awaits approval; approve it before citing the evidence"
|
|
301
|
+
)
|
|
302
|
+
return operation.results[0], operation
|
|
303
|
+
|
|
304
|
+
# -- helpers ------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def _writable_state(self) -> BookState:
|
|
307
|
+
state = self.state()
|
|
308
|
+
if state.integrity_issues:
|
|
309
|
+
first = state.integrity_issues[0]
|
|
310
|
+
raise IntegrityError(
|
|
311
|
+
f"refusing to write: {len(state.integrity_issues)} record(s) fail integrity checks "
|
|
312
|
+
f"(first: {first.record}: {first.message}); run `wealthbraid verify`"
|
|
313
|
+
)
|
|
314
|
+
return state
|
|
315
|
+
|
|
316
|
+
def _operation(self, operation_id: str) -> OperationState:
|
|
317
|
+
return self.state().operations[operation_id]
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _build_operation( # noqa: PLR0913
|
|
321
|
+
*,
|
|
322
|
+
tool: str,
|
|
323
|
+
summary: str,
|
|
324
|
+
changes: Sequence[Mapping[str, Any]],
|
|
325
|
+
reasoning: str,
|
|
326
|
+
confidence: float,
|
|
327
|
+
evidence: Sequence[str],
|
|
328
|
+
inputs: Mapping[str, Any] | None,
|
|
329
|
+
) -> OperationData:
|
|
330
|
+
normalised = []
|
|
331
|
+
for index, raw in enumerate(changes):
|
|
332
|
+
change = parse_data_change(raw, index)
|
|
333
|
+
if not has_refs(change.data):
|
|
334
|
+
change = change.model_copy(update={"data": dump_data(parse_data(RecordKind(change.kind), change.data))})
|
|
335
|
+
normalised.append(change)
|
|
336
|
+
try:
|
|
337
|
+
return OperationData(
|
|
338
|
+
tool=tool,
|
|
339
|
+
summary=summary,
|
|
340
|
+
inputs=dict(inputs or {}),
|
|
341
|
+
evidence=list(evidence),
|
|
342
|
+
reasoning=reasoning,
|
|
343
|
+
confidence=confidence,
|
|
344
|
+
changes=normalised,
|
|
345
|
+
)
|
|
346
|
+
except PydanticValidationError as exc:
|
|
347
|
+
raise ValidationError(f"invalid operation: {format_validation_error(exc)}") from exc
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _stage_decision( # noqa: PLR0913
|
|
351
|
+
staged: PendingAppend,
|
|
352
|
+
*,
|
|
353
|
+
op_record: Record,
|
|
354
|
+
operation: OperationData,
|
|
355
|
+
actor: str,
|
|
356
|
+
verdict: str,
|
|
357
|
+
note: str | None,
|
|
358
|
+
) -> None:
|
|
359
|
+
decision = DecisionData(operation=op_record.id, verdict=verdict, note=note) # type: ignore[arg-type]
|
|
360
|
+
staged.add(RecordKind.DECISION, dump_data(decision), actor=actor)
|
|
361
|
+
if verdict != "approve":
|
|
362
|
+
return
|
|
363
|
+
produced: list[str] = []
|
|
364
|
+
for change in operation.changes:
|
|
365
|
+
data = resolve_refs(change.data, produced)
|
|
366
|
+
record = staged.add(RecordKind(change.kind), data, actor=op_record.actor, operation=op_record.id)
|
|
367
|
+
produced.append(record.id)
|
|
368
|
+
staged.add(RecordKind.APPLIED, {"operation": op_record.id, "results": produced}, actor=actor)
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def _check(state: BookState, records: Sequence[Record], message: str) -> None:
|
|
372
|
+
problems = []
|
|
373
|
+
for record in records:
|
|
374
|
+
problems.extend(state.apply(record))
|
|
375
|
+
if problems:
|
|
376
|
+
details = "; ".join(issue.message for issue in problems)
|
|
377
|
+
raise ValidationError(f"{message}: {details}")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def parse_data_change(raw: Mapping[str, Any], index: int) -> ChangeData:
|
|
381
|
+
"""Parse one proposed change.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
raw: The change mapping.
|
|
385
|
+
index: Its position, for error messages.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
The parsed :class:`ChangeData`.
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
ValidationError: If the change is malformed.
|
|
392
|
+
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
return ChangeData.model_validate(raw)
|
|
396
|
+
except PydanticValidationError as exc:
|
|
397
|
+
raise ValidationError(f"change {index}: {format_validation_error(exc)}") from exc
|