from datetime import date as dt_date
from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
from pathlib import Path
-from typing import Any, Callable, Iterator, Optional, Self
+from typing import Any, Callable, Iterator, Optional, Self, Union
SPACE = ' '
return self.intro_line.target
-class _DatBlock(_LinesBlock):
+class _LedgerNode:
+ _next: Optional['_DatBlock'] = None
+
+ def _set_neighbor(
+ self,
+ new_this: Optional[Union['_DatBlock', '_StartNode']],
+ this: str,
+ that: str,
+ ) -> None:
+ if (old_this := getattr(self, f'_{this}')):
+ setattr(old_this, f'_{that}', None)
+ if new_this:
+ if (new_this_that := getattr(new_this, f'_{that}')):
+ setattr(new_this_that, f'_{this}', None)
+ setattr(new_this, f'_{that}', self)
+ setattr(self, f'_{this}', new_this)
+
+ @property
+ def next(self) -> Optional['_DatBlock']:
+ 'Successor in chain.'
+ return self._next
+
+ @next.setter
+ def next(self, new_next: Optional['_DatBlock']) -> None:
+ self._set_neighbor(new_next, 'next', 'prev')
+
+
+class _StartNode(_LedgerNode):
+ pass
+
+
+class _DatBlock(_LinesBlock, _LedgerNode):
'Unit of lines with optional .booking, and (possibly zero) .gap_lines.'
- _prev: Optional[Self] = None
- _next: Optional[Self] = None
+ _prev: Optional[Self | _StartNode] = None
@classmethod
def from_lines(cls, lines: tuple[str, ...]) -> tuple['_DatBlock', ...]:
'Sequence of DatBlocks parsed from lines.'
i_block: _DatBlock = cls()
- blocks = [i_block]
- for dat_line in (_DatLine(line) for line in lines):
+ for dat_line in (_DatLine(line.rstrip()) for line in lines):
if (not dat_line.len_indent) and i_block.indented:
- blocks += [i_block]
i_block.next = _DatBlock()
i_block = i_block.next
i_block.add((dat_line, ))
- return tuple(blocks)
+ blocks = [i_block]
+ while i_block.prev:
+ i_block = i_block.prev
+ blocks += [i_block]
+ return tuple(reversed(blocks))
@property
def indented(self) -> bool:
def id_(self) -> int:
'Return index in chain.'
count = -1
- block_iterated: Optional[_DatBlock] = self
- while block_iterated:
- block_iterated = block_iterated.prev
+ i_block: Optional[_DatBlock] = self
+ while isinstance(i_block, _DatBlock):
+ i_block = i_block.prev
count += 1
return count
return 'date < previous date'
return ''
- def _set_neighbor(
- self,
- new_this: Optional[Self],
- this: str,
- that: str,
- ) -> None:
- if (old_this := getattr(self, f'_{this}')):
- setattr(old_this, f'_{that}', None)
- if new_this:
- if (new_this_that := getattr(new_this, f'_{that}')):
- setattr(new_this_that, f'_{this}', None)
- setattr(new_this, f'_{that}', self)
- setattr(self, f'_{this}', new_this)
-
@property
- def next(self) -> Optional[Self]:
- 'Successor in chain.'
- return self._next
-
- @next.setter
- def next(self, new_next: Optional[Self]) -> None:
- self._set_neighbor(new_next, 'next', 'prev')
+ def prev_any(self) -> Self | _StartNode:
+ 'Predecessor in chain – including _StartNode.'
+ assert self._prev is not None
+ return self._prev
@property
def prev(self) -> Optional[Self]:
- 'Predecessor in chain.'
- return self._prev
+ 'Predecessor in chain – unless _StartNode, then None.'
+ return self._prev if isinstance(self._prev, _DatBlock) else None
@prev.setter
- def prev(self, new_prev: Optional[Self]):
+ def prev(self, new_prev: Optional[Self | _StartNode]):
self._set_neighbor(new_prev, 'prev', 'next')
@property
def move(self, up: bool) -> None:
'Move up/down in chain.'
- old_prev = self.prev
+ old_prev = self.prev_any
old_next = self.next
if up:
- assert old_prev is not None
- if old_prev.prev:
- old_prev.prev.next = self
+ assert isinstance(old_prev, _DatBlock)
+ if old_prev.prev_any:
+ old_prev.prev_any.next = self
self.next = old_prev
old_prev.next = old_next
else:
self.prev = old_next
old_next.prev = old_prev
- def fix_position(self):
- 'Move around in chain until properly positioned by .date.'
- while self.prev and self.prev.date > self.date:
- self.move(up=True)
- while self.next and self.next.date < self.date:
- self.move(up=False)
-
- def copy_to_current_date(self) -> Self:
+ def fix_position(self) -> '_DatBlock':
+ 'Move around in chain until properly positioned by .date, maybe fuse.'
+ if self.date:
+ while self.prev and self.prev.date and self.prev.date > self.date:
+ self.move(up=True)
+ while self.next and self.next.date and self.next.date <= self.date:
+ self.move(up=False)
+ if self.next and not self.booking:
+ old_next = self.next
+ old_next.add(self.lines, at_end=False)
+ old_next.prev = self.prev
+ return old_next
+ if self.prev and not self.prev.booking:
+ old_prev = self.prev
+ old_prev.add(self.lines)
+ old_prev.next = self.next
+ return old_prev
+ return self
+
+ def copy_to_current_date(self) -> '_DatBlock':
'Make copy of same lines but date of now, position accordingly.'
copy = self.__class__()
copy.add(tuple(_DatLine(line.raw) for line in self.gap_lines))
at_end=False)
copy.add(tuple(_DatLine(line.raw)
for line in self.booking.transfer_lines))
- if self.next:
- self.next.prev = copy
- self.next = copy
- copy.fix_position()
- return copy
+ copy.insert_self_after(self)
+ return copy.fix_position()
+
+ def insert_self_after(self, other: Union[_StartNode, Self]) -> None:
+ 'Insert self between other and other.next.'
+ self.next = other.next
+ other.next = self
class Ledger:
'Collection of _DatBlocks, _Bookings and _Accounts derived from them.'
- _blocks_start: Optional[_DatBlock]
+ _start_node: _StartNode
def __init__(self, path_dat: Path) -> None:
self._path_dat = path_dat
def load(self) -> None:
'Re-)read ledger from file at ._path_dat.'
- blocks = _DatBlock.from_lines(
- tuple(self._path_dat.read_text(encoding='utf8').splitlines()))
- self._blocks_start = blocks[0] if blocks else _DatBlock()
+ self._start_node = _StartNode()
+ if (blocks := _DatBlock.from_lines(
+ tuple(self._path_dat.read_text(encoding='utf8'
+ ).splitlines()))):
+ self._start_node.next = blocks[0]
self.last_save_hash = self._hash_dat_lines()
@property
def blocks(self) -> list[_DatBlock]:
'Return blocks chain as list.'
blocks = []
- block = self._blocks_start
+ block = self._start_node.next
while block:
blocks += [block]
block = block.next
'If ._dat_lines different to those of last .load().'
return self._hash_dat_lines() != self.last_save_hash
- def move_block(self, idx_from: int, up: bool) -> int:
- 'Move _DatBlock of idx_from step up or downwards.'
- block = self.blocks[idx_from]
- block.move(up)
- return block.id_
-
def rewrite_block(self, old_id: int, new_lines: list[str]) -> int:
'Rewrite with new_lines, move if changed date.'
old_block = self.blocks[old_id]
- old_prev, old_next = old_block.prev, old_block.next
- new_blocks = _DatBlock.from_lines(tuple(new_lines))
+ old_prev, old_next = old_block.prev_any, old_block.next
+ new_blocks = list(_DatBlock.from_lines(tuple(new_lines)))
if old_next:
- if not new_blocks[0].booking:
- assert len(new_blocks) == 1
- old_next.add(new_blocks[0].lines)
- old_next.prev = old_prev
- return old_next.id_
old_next.prev = new_blocks[-1]
- if old_prev:
- old_prev.next = new_blocks[0]
- else:
- self._blocks_start = new_blocks[0]
- for block in new_blocks:
- block.fix_position()
+ old_prev.next = new_blocks[0]
+ for idx, block in enumerate(new_blocks):
+ new_blocks[idx] = block.fix_position()
return new_blocks[0].id_
def add_empty_block(self) -> int:
- 'Add new _DatBlock of empty _Booking to end of ledger.'
+ 'Add new _DatBlock of empty _Booking of today to ledger.'
new_block = _DatBlock()
- new_block.add((_DatLine(f'{dt_date.today().isoformat()} ?'), ))
- self.blocks[-1].next = new_block
- new_block.fix_position()
- return new_block.id_
-
- def copy_block(self, id_: int) -> int:
- 'Add copy _DatBlock of id_ but with current date.'
- copy = self.blocks[id_].copy_to_current_date()
- return copy.id_
+ date_today = dt_date.today().isoformat()
+ new_block.add((tuple(_DatLine(s) for s in ('', f'{date_today} ?'))))
+ new_block.insert_self_after(self._start_node)
+ return new_block.fix_position().id_
def view_ctx_balance(self, id_: int = -1) -> dict[str, Any]:
'All context data relevant for rendering a balance view.'