icedcoffee-sync 0.1.0.dev5__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.
@@ -0,0 +1,3 @@
1
+ """A typed client for the Frappe REST API."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,6 @@
1
+ """Declarative apply: desired state, diff, ordered execution.
2
+
3
+ Generic by construction. Nothing here knows what a chart of accounts contains,
4
+ only how one is shaped. Dependency direction: `icedcoffee_sync.erpnext` may import from
5
+ this module, never the reverse.
6
+ """
@@ -0,0 +1,522 @@
1
+ """Compare a desired chart against a live one and say what would change.
2
+
3
+ Accounts have no intrinsic key: identity comes from position, walked
4
+ top-down. A root is matched by root_type, since ERPNext rejects an Account
5
+ with no `parent_account` and so roots are never created. A child is matched
6
+ against its parent's already-resolved *live* identity, not a pre-apply
7
+ index, so a renamed parent's children still match.
8
+
9
+ Extras (accounts the chart doesn't describe) are reported and left alone --
10
+ deletion is opt-in. A single refusal makes the whole plan unapplyable, since
11
+ a partially applied chart leaves a tree shape nobody chose to diff against.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import enum
18
+ from collections.abc import Iterator
19
+
20
+ from icedcoffee_sync.apply.model import DesiredAccount, LiveAccount, MatchKey
21
+
22
+
23
+ class RefusalReason(enum.StrEnum):
24
+ """Why the diff declined to decide."""
25
+
26
+ HAS_GL_ENTRIES = "has_gl_entries"
27
+ AMBIGUOUS_MATCH = "ambiguous_match"
28
+ GROUP_FLAG_CHANGED = "group_flag_changed"
29
+ ROOT_TYPE_CHANGED = "root_type_changed"
30
+ ROOT_NOT_UNIQUE = "root_not_unique"
31
+ NUMBER_COLLISION = "number_collision"
32
+
33
+
34
+ @dataclasses.dataclass(frozen=True)
35
+ class CreateAccount:
36
+ """An account in the chart with no counterpart on the site."""
37
+
38
+ desired: DesiredAccount
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True)
42
+ class RenameAccount:
43
+ """A matched account whose number or name differs.
44
+
45
+ One bucket for both because ERPNext names a document
46
+ `{number} - {name} - {abbr}`: changing either field is the same single
47
+ rename call, and ordering them is the same problem either way.
48
+
49
+ They are not the same thing to a person, though, so `changes_number` and
50
+ `changes_name` tell them apart. A renumber moves the GL code the bank
51
+ shows against every transaction; a rename only changes the label on it.
52
+ """
53
+
54
+ live: LiveAccount
55
+ desired: DesiredAccount
56
+
57
+ @property
58
+ def changes_number(self) -> bool:
59
+ """True when the account's number moves."""
60
+ return self.live.number != self.desired.number
61
+
62
+ @property
63
+ def changes_name(self) -> bool:
64
+ """True when the account's name changes."""
65
+ return self.live.name != self.desired.name
66
+
67
+
68
+ @dataclasses.dataclass(frozen=True)
69
+ class RetypeAccount:
70
+ """A matched account whose account_type differs."""
71
+
72
+ live: LiveAccount
73
+ desired: DesiredAccount
74
+
75
+
76
+ @dataclasses.dataclass(frozen=True)
77
+ class ExtraAccount:
78
+ """An account on the site that the chart does not describe."""
79
+
80
+ live: LiveAccount
81
+
82
+
83
+ @dataclasses.dataclass(frozen=True)
84
+ class EnableAccount:
85
+ """A matched account the chart wants enabled, but the site has disabled.
86
+
87
+ The chart format has no field for "disabled", so any matched account is
88
+ implicitly wanted enabled -- `disabled=True` is always drift. Never
89
+ checked against `with_entries`: re-enabling touches neither GL entries
90
+ nor the document's identity.
91
+ """
92
+
93
+ live: LiveAccount
94
+ desired: DesiredAccount
95
+
96
+
97
+ @dataclasses.dataclass(frozen=True)
98
+ class Refusal:
99
+ """A situation the diff will not resolve on its own.
100
+
101
+ `live_document_names` holds the live accounts the refusal is about -- one
102
+ for a mismatch, several for an ambiguous match, none when the chart wants
103
+ an account the site doesn't have.
104
+ """
105
+
106
+ key: MatchKey
107
+ reason: RefusalReason
108
+ detail: str
109
+ live_document_names: tuple[str, ...] = ()
110
+
111
+
112
+ @dataclasses.dataclass(frozen=True)
113
+ class ChartPlan:
114
+ """Everything the diff concluded.
115
+
116
+ `enables` defaults to empty and sits last so positional `ChartPlan(...)`
117
+ construction (test fixtures, especially) needs no updating.
118
+ """
119
+
120
+ creates: list[CreateAccount]
121
+ renames: list[RenameAccount]
122
+ retypes: list[RetypeAccount]
123
+ extras: list[ExtraAccount]
124
+ refusals: list[Refusal]
125
+ enables: list[EnableAccount] = dataclasses.field(default_factory=list)
126
+
127
+ @property
128
+ def is_applyable(self) -> bool:
129
+ """True when nothing was refused. Extras do not block."""
130
+ return not self.refusals
131
+
132
+ @property
133
+ def is_empty(self) -> bool:
134
+ """True when the site already matches the chart."""
135
+ return not (self.creates or self.renames or self.retypes or self.enables)
136
+
137
+
138
+ def diff(
139
+ desired: list[DesiredAccount],
140
+ live: list[LiveAccount],
141
+ accounts_with_entries: set[str],
142
+ ) -> ChartPlan:
143
+ """Diff a desired chart against a live one.
144
+
145
+ Args:
146
+ desired: The chart as the file describes it.
147
+ live: Every account the site has for the company.
148
+ accounts_with_entries: Live document names carrying ledger entries.
149
+ Only accounts this diff would otherwise *change* are checked
150
+ against it.
151
+
152
+ Returns:
153
+ Everything the walk concluded, refusals included.
154
+
155
+ Raises:
156
+ KeyError: A desired or live account names a parent outside its set.
157
+ ValueError: The chart declares two roots of the same root_type.
158
+ """
159
+ walk = _Walk(
160
+ with_entries=accounts_with_entries,
161
+ keys=_keys(desired),
162
+ desired_children=_desired_children(desired),
163
+ live_children=_live_children(live),
164
+ )
165
+ walk.roots()
166
+ walk.number_collisions(live)
167
+ extras = [ExtraAccount(a) for a in live if a.document_name not in walk.matched]
168
+ return ChartPlan(walk.creates, walk.renames, walk.retypes, extras, walk.refusals, walk.enables)
169
+
170
+
171
+ @dataclasses.dataclass
172
+ class _Walk:
173
+ """One diff in progress: both trees, and what the walk has concluded so far."""
174
+
175
+ with_entries: set[str]
176
+ keys: dict[str, MatchKey]
177
+ desired_children: dict[str | None, list[DesiredAccount]]
178
+ live_children: dict[str | None, list[LiveAccount]]
179
+ creates: list[CreateAccount] = dataclasses.field(default_factory=list)
180
+ renames: list[RenameAccount] = dataclasses.field(default_factory=list)
181
+ retypes: list[RetypeAccount] = dataclasses.field(default_factory=list)
182
+ enables: list[EnableAccount] = dataclasses.field(default_factory=list)
183
+ refusals: list[Refusal] = dataclasses.field(default_factory=list)
184
+ matched: set[str] = dataclasses.field(default_factory=set)
185
+
186
+ def roots(self) -> None:
187
+ """Match each chart root to the live root of the same root_type."""
188
+ live_roots: dict[str, list[LiveAccount]] = {}
189
+ for root in self.live_children.get(None, []):
190
+ live_roots.setdefault(root.root_type, []).append(root)
191
+
192
+ declared: dict[str, DesiredAccount] = {}
193
+ for want in self.desired_children.get(None, []):
194
+ if want.root_type in declared:
195
+ raise ValueError(
196
+ f"chart roots {declared[want.root_type].number} and {want.number} are both "
197
+ f"root_type {want.root_type!r}; a root is matched by root_type alone, so two "
198
+ f"of a kind cannot be told apart"
199
+ )
200
+ declared[want.root_type] = want
201
+
202
+ candidates = live_roots.get(want.root_type, [])
203
+ if len(candidates) == 1:
204
+ self.pair(want, candidates[0])
205
+ continue
206
+
207
+ found = ", ".join(c.document_name for c in candidates) or "none"
208
+ self.refuse(
209
+ want,
210
+ RefusalReason.ROOT_NOT_UNIQUE,
211
+ f"chart root {want.number} {want.name!r} needs exactly one live root of root_type "
212
+ f"{want.root_type!r}; the site has {len(candidates)} ({found}). A root cannot be "
213
+ f"created: ERPNext rejects an Account with no parent_account",
214
+ candidates,
215
+ )
216
+ self.consume(candidates)
217
+
218
+ def pair(self, want: DesiredAccount, have: LiveAccount) -> None:
219
+ """Reconcile one matched pair, then match their children.
220
+
221
+ Children are walked even for a refused pair: nothing runs from a
222
+ refused plan, so a create proposed under a disputed parent is just
223
+ information.
224
+ """
225
+ self.matched.add(have.document_name)
226
+ self.reconcile(want, have)
227
+ self.children(want, have)
228
+
229
+ def reconcile(self, want: DesiredAccount, have: LiveAccount) -> None:
230
+ """Decide what changes on a matched pair, or refuse to."""
231
+ if have.is_group != want.is_group:
232
+ self.refuse(
233
+ want,
234
+ RefusalReason.GROUP_FLAG_CHANGED,
235
+ f"{have.document_name} is_group={have.is_group}, chart says {want.is_group}",
236
+ [have],
237
+ )
238
+ return
239
+
240
+ if have.root_type != want.root_type:
241
+ self.refuse(
242
+ want,
243
+ RefusalReason.ROOT_TYPE_CHANGED,
244
+ f"{have.document_name} root_type={have.root_type!r}, chart says {want.root_type!r}",
245
+ [have],
246
+ )
247
+ return
248
+
249
+ wants_rename = have.number != want.number or have.name != want.name
250
+ wants_retype = (have.account_type or None) != (want.account_type or None)
251
+
252
+ if (wants_rename or wants_retype) and have.document_name in self.with_entries:
253
+ self.refuse(
254
+ want,
255
+ RefusalReason.HAS_GL_ENTRIES,
256
+ f"{have.document_name} carries ledger entries and cannot be modified",
257
+ [have],
258
+ )
259
+ return
260
+
261
+ if wants_rename:
262
+ self.renames.append(RenameAccount(have, want))
263
+ if wants_retype:
264
+ self.retypes.append(RetypeAccount(have, want))
265
+ if have.disabled:
266
+ self.enables.append(EnableAccount(have, want))
267
+
268
+ def children(self, want: DesiredAccount, have: LiveAccount) -> None:
269
+ """Match a matched pair's children to each other, by number and then by name.
270
+
271
+ Only this pair's own children are candidates -- keeps a renamed
272
+ parent from stranding its subtree, and makes ambiguity a
273
+ sibling-only concern rather than a chart-wide one.
274
+
275
+ **Number first.** In a chart of accounts the number is the account's
276
+ identity and the name is a label on it: the chart file is keyed on
277
+ number, ERPNext enforces one account per number per company, and the
278
+ GL codes our bank shows are those numbers. So an account whose name
279
+ the chart rewrites is the same account, and matching it by name would
280
+ make it a create beside the original -- which then collides with the
281
+ number the original still holds, and the plan refuses the very change
282
+ it was asked to make.
283
+
284
+ **Name second**, for the live accounts a number cannot reach: the
285
+ stock chart a wizard site starts with carries no numbers at all, so
286
+ number-only matching would strand every one of them and re-create the
287
+ chart alongside itself.
288
+
289
+ Two passes rather than one loop, so a name cannot claim a sibling
290
+ that some later chart entry owns by number. A number match is never
291
+ ambiguous the way a name match is, which is also why a number match
292
+ settles what would otherwise be two same-named siblings.
293
+ """
294
+ siblings = self.live_children.get(have.document_name, [])
295
+ kids = self.desired_children.get(want.number, [])
296
+
297
+ counterparts: list[LiveAccount | None] = [None] * len(kids)
298
+ taken: set[str] = set()
299
+ self._claim_by_number(kids, siblings, counterparts, taken)
300
+ refused = self._refuse_contested_claims(kids, siblings, counterparts, have)
301
+ refused |= self._claim_by_name(kids, siblings, counterparts, taken, have, refused)
302
+
303
+ for index, kid in enumerate(kids):
304
+ if index in refused:
305
+ continue
306
+ counterpart = counterparts[index]
307
+ if counterpart is None:
308
+ self.create(kid)
309
+ else:
310
+ self.pair(kid, counterpart)
311
+
312
+ @staticmethod
313
+ def _claim_by_number(
314
+ kids: list[DesiredAccount],
315
+ siblings: list[LiveAccount],
316
+ counterparts: list[LiveAccount | None],
317
+ taken: set[str],
318
+ ) -> None:
319
+ """Pair each chart entry with the sibling already holding its number."""
320
+ by_number = {child.number: child for child in siblings if child.number}
321
+ for index, kid in enumerate(kids):
322
+ match = by_number.get(kid.number)
323
+ if match is not None:
324
+ counterparts[index] = match
325
+ taken.add(match.document_name)
326
+
327
+ def _refuse_contested_claims(
328
+ self,
329
+ kids: list[DesiredAccount],
330
+ siblings: list[LiveAccount],
331
+ counterparts: list[LiveAccount | None],
332
+ have: LiveAccount,
333
+ ) -> set[int]:
334
+ """Refuse where one chart entry owns a live account by number and another names it.
335
+
336
+ The signature of an identity swap: the chart moves an account to a new
337
+ number while a different entry takes the number it left, and both point
338
+ at the same live document. Preferring the number silently repurposes
339
+ it -- and while the GL-entry guard stops that for an account with
340
+ ledger history, an *empty* account can still be linked from Bank
341
+ Account records, tax templates or the Company defaults, and every one
342
+ of those links follows the rename to the wrong account.
343
+
344
+ Which of the two readings the author meant is genuinely unknowable
345
+ here, so this refuses instead of guessing. Splitting the change across
346
+ two applies resolves it.
347
+
348
+ A swap of names between two accounts that each keep their number is
349
+ not this: there the contested account is claimed by number too, and
350
+ both entries end up where they belong.
351
+ """
352
+ claimed_by_number = {
353
+ counterpart.document_name: index for index, counterpart in enumerate(counterparts) if counterpart
354
+ }
355
+ refused: set[int] = set()
356
+ for index, kid in enumerate(kids):
357
+ if counterparts[index] is not None:
358
+ continue
359
+ for sibling in siblings:
360
+ if sibling.name != kid.name:
361
+ continue
362
+ owner = claimed_by_number.get(sibling.document_name)
363
+ if owner is None or owner == index:
364
+ continue
365
+ self.refuse(
366
+ kid,
367
+ RefusalReason.AMBIGUOUS_MATCH,
368
+ f"chart {kid.number} {kid.name!r} matches {sibling.document_name} by name, but chart "
369
+ f"{kids[owner].number} already claims it by number; apply the renumber and the "
370
+ f"reuse of its old number as two separate changes",
371
+ [sibling],
372
+ )
373
+ self.consume([sibling])
374
+ refused.add(index)
375
+ break
376
+ return refused
377
+
378
+ def _claim_by_name(
379
+ self,
380
+ kids: list[DesiredAccount],
381
+ siblings: list[LiveAccount],
382
+ counterparts: list[LiveAccount | None],
383
+ taken: set[str],
384
+ have: LiveAccount,
385
+ already_refused: set[int],
386
+ ) -> set[int]:
387
+ """Pair what the numbers left over, by name. Returns the indices refused as ambiguous."""
388
+ by_name: dict[str, list[LiveAccount]] = {}
389
+ for child in siblings:
390
+ by_name.setdefault(child.name, []).append(child)
391
+
392
+ refused: set[int] = set()
393
+ for index, kid in enumerate(kids):
394
+ if counterparts[index] is not None or index in already_refused:
395
+ continue
396
+ candidates = [c for c in by_name.get(kid.name, []) if c.document_name not in taken]
397
+ if len(candidates) > 1:
398
+ found = ", ".join(c.document_name for c in candidates)
399
+ self.refuse(
400
+ kid,
401
+ RefusalReason.AMBIGUOUS_MATCH,
402
+ f"{len(candidates)} children of {have.document_name} are named {kid.name!r}: {found}",
403
+ candidates,
404
+ )
405
+ self.consume(candidates)
406
+ taken.update(c.document_name for c in candidates)
407
+ refused.add(index)
408
+ elif candidates:
409
+ counterparts[index] = candidates[0]
410
+ taken.add(candidates[0].document_name)
411
+ return refused
412
+
413
+ def number_collisions(self, live: list[LiveAccount]) -> None:
414
+ """Refuse any create or renumber that would land on a number a live account still holds.
415
+
416
+ Not local to a matched pair, unlike `reconcile`'s checks: the
417
+ contested account can be anywhere in the tree. Checked against the
418
+ state *after* this plan's own renames land -- `apply_chart` runs
419
+ renames before creates, so a number this plan itself vacates is
420
+ not a collision, or a valid renumbering plan would refuse itself
421
+ forever.
422
+
423
+ Args:
424
+ live: Every account the site has for the company -- the
425
+ contested account may be one this walk never visited.
426
+ """
427
+ live_by_number = {a.number: a for a in live if a.number}
428
+ vacated = {r.live.number for r in self.renames if r.live.number and r.live.number != r.desired.number}
429
+
430
+ def check(want: DesiredAccount, counterpart: LiveAccount | None) -> None:
431
+ if not want.number or want.number in vacated:
432
+ return
433
+ contested = live_by_number.get(want.number)
434
+ if contested is None:
435
+ return
436
+ if counterpart is not None and contested.document_name == counterpart.document_name:
437
+ return
438
+ self.refuse(
439
+ want,
440
+ RefusalReason.NUMBER_COLLISION,
441
+ f"chart assigns {want.number} to {want.name!r} but {want.number} is held by "
442
+ f"{contested.document_name!r}",
443
+ [contested],
444
+ )
445
+
446
+ for change in self.creates:
447
+ check(change.desired, None)
448
+ for change in self.renames:
449
+ check(change.desired, change.live)
450
+
451
+ def create(self, want: DesiredAccount) -> None:
452
+ """Create an unmatched account and, with it, everything beneath it."""
453
+ self.creates.append(CreateAccount(want))
454
+ for child in self.desired_children.get(want.number, []):
455
+ self.create(child)
456
+
457
+ def consume(self, accounts: list[LiveAccount]) -> None:
458
+ """Mark live subtrees as accounted for without matching them.
459
+
460
+ A refused account must not also surface as an extra -- refused and
461
+ unmentioned at once makes no sense -- and neither must its descendants,
462
+ which the walk cannot reach now that it has stopped at their ancestor.
463
+ """
464
+ for account in accounts:
465
+ for node in self._subtree(account):
466
+ self.matched.add(node.document_name)
467
+
468
+ def refuse(
469
+ self,
470
+ want: DesiredAccount,
471
+ reason: RefusalReason,
472
+ detail: str,
473
+ accounts: list[LiveAccount],
474
+ ) -> None:
475
+ """Record a refusal against a desired account and the live accounts involved."""
476
+ names = tuple(a.document_name for a in accounts)
477
+ self.refusals.append(Refusal(self.keys[want.number], reason, detail, names))
478
+
479
+ def _subtree(self, account: LiveAccount) -> Iterator[LiveAccount]:
480
+ yield account
481
+ for child in self.live_children.get(account.document_name, []):
482
+ yield from self._subtree(child)
483
+
484
+
485
+ def _keys(desired: list[DesiredAccount]) -> dict[str, MatchKey]:
486
+ """Label every desired account as (its name, its parent's name) for reporting."""
487
+ by_number = {a.number: a for a in desired}
488
+ keys: dict[str, MatchKey] = {}
489
+ for account in desired:
490
+ if account.parent_number is None:
491
+ keys[account.number] = (account.name, None)
492
+ continue
493
+ parent = by_number.get(account.parent_number)
494
+ if parent is None:
495
+ raise KeyError(f"account {account.number} names parent {account.parent_number}, which is not in the chart")
496
+ keys[account.number] = (account.name, parent.name)
497
+ return keys
498
+
499
+
500
+ def _desired_children(desired: list[DesiredAccount]) -> dict[str | None, list[DesiredAccount]]:
501
+ """Group desired accounts under their parent's number; roots under None."""
502
+ children: dict[str | None, list[DesiredAccount]] = {}
503
+ for account in desired:
504
+ children.setdefault(account.parent_number, []).append(account)
505
+ return children
506
+
507
+
508
+ def _live_children(live: list[LiveAccount]) -> dict[str | None, list[LiveAccount]]:
509
+ """Group live accounts under their parent's document name; roots under None.
510
+
511
+ Raises `KeyError` if a parent names a document outside the given set:
512
+ the walk descends only from roots, so such an account would otherwise be
513
+ silently misreported as an extra rather than one nobody looked at.
514
+ """
515
+ by_document_name = {a.document_name: a for a in live}
516
+ children: dict[str | None, list[LiveAccount]] = {}
517
+ for account in live:
518
+ parent = account.parent_document_name
519
+ if parent is not None and parent not in by_document_name:
520
+ raise KeyError(f"account {account.document_name!r} names parent {parent!r}, which is not in the given set")
521
+ children.setdefault(parent, []).append(account)
522
+ return children