# standard libs
from pathlib import Path
-from typing import Any
+from typing import Any, Optional
# non-standard libs
from plomlib.web import PlomHttpHandler, PlomHttpServer, PlomQueryMap
-from ledgplom.ledger import Account, DatLine, Ledger
+from ledgplom.ledger import (
+ Account, BookingLine, DatLine, IntroLine, Ledger, TransferLine)
_SERVER_PORT = 8084
if lineno not in lineno_to_inputs:
lineno_to_inputs[lineno] = []
lineno_to_inputs[lineno] += [toks[2]]
- indent = ' '
for lineno, input_names in lineno_to_inputs.items():
- data = ''
- comment = self.postvars.first(f'line_{lineno}_comment')
- for name in input_names:
- input_ = self.postvars.first(f'line_{lineno}_{name}'
- ).strip()
- if name == 'date':
- data = input_
- elif name == 'target':
- data += f' {input_}'
- elif name == 'error':
- data = f'{indent}{input_}'
- elif name == 'account':
- data = f'{indent}{input_}'
- elif name in {'amount', 'currency'}:
- data += f' {input_}'
- new_lines += [
- DatLine(f'{data} ; {comment}' if comment else data)]
+ line_d = {key: self.postvars.first(f'line_{lineno}_{key}')
+ for key in input_names}
+ booking_line: Optional[BookingLine] = None
+ if 'date' in line_d:
+ booking_line = IntroLine(
+ line_d['date'],
+ line_d['target'])
+ elif 'account' in line_d:
+ booking_line = TransferLine(
+ line_d['account'],
+ line_d['amount'],
+ line_d['currency'])
+ if booking_line:
+ new_lines += [booking_line.to_dat_line(line_d['comment'])]
+ else:
+ new_lines += [DatLine(line_d['error'], line_d['comment'],
+ add_indent=True)]
else: # edit_raw
- new_lines += [DatLine(line) for line
+ new_lines += [DatLine.from_raw(line) for line
in self.postvars.first('booking').splitlines()]
new_id = self.server.ledger.rewrite_booking(booking.id_, new_lines)
return Path('/bookings').joinpath(f'{new_id}')
def get_balance(self, ctx) -> None:
"""Display tree of calculated Accounts over .bookings[:up_incl+1]."""
id_ = int(self.params.first('up_incl') or '-1')
- ctx['roots'] = [ac for ac in self.server.ledger.accounts.values()
- if not ac.parent]
+ roots = [ac for ac in self.server.ledger.accounts.values()
+ if not ac.parent]
+ ctx['roots'] = sorted(roots, key=lambda r: r.basename)
ctx['valid'] = self.server.ledger.bookings_valid_up_incl(id_)
ctx['booking'] = self.server.ledger.bookings[id_]
ctx['path_up_incl'] = f'{self.path_toks[1]}?up_incl='
"""Actual ledger classes."""
# standard libs
+from abc import ABC, abstractmethod
from datetime import date as dt_date
from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
from pathlib import Path
_PREFIX_DEF = 'def '
+_DEFAULT_INDENT = 2 * ' '
class _Dictable:
dictables = {'booked', 'code', 'comment', 'error', 'is_intro'}
prev_line_empty: bool
- def __init__(self, line: str) -> None:
- self.raw = line[:]
- halves = [t.rstrip() for t in line.split(';', maxsplit=1)]
- self.comment = halves[1] if len(halves) > 1 else ''
- self.code = halves[0]
+ def __init__(
+ self,
+ code: str,
+ comment: str,
+ add_indent: bool = False,
+ raw: Optional[str] = None
+ ) -> None:
+ self.comment = comment
+ self.code = f'{_DEFAULT_INDENT}{code}' if add_indent else code
+ if raw:
+ self.raw = raw
+ else:
+ self.raw = self.code
+ if self.comment:
+ self.raw += f' ; {self.comment}'
self.booking: Optional['_Booking'] = None
- self.booked: Optional[_BookingLine] = None
+ self.booked: Optional[BookingLine] = None
+
+ @classmethod
+ def new_empty(cls) -> Self:
+ """Create empty DatLine."""
+ return cls('', '', raw='')
+
+ def copy_unbooked(self) -> 'DatLine':
+ """Create DatLine of .code, .comment, .raw, but no Booking ties yet."""
+ return DatLine(self.code, self.comment, raw=self.raw)
+
+ @classmethod
+ def from_raw(cls, line: str) -> Self:
+ """Parse line into new DatLine."""
+ halves = [t.rstrip() for t in line.split(';', maxsplit=1)]
+ comment = halves[1] if len(halves) > 1 else ''
+ code = halves[0]
+ return cls(code, comment, raw=line)
@property
def comment_instructions(self) -> dict[str, str]:
@property
def is_intro(self) -> bool:
"""Return if intro line of a _Booking."""
- return isinstance(self.booked, _IntroLine)
+ return isinstance(self.booked, IntroLine)
@property
def booking_id(self) -> int:
@property
def error(self) -> str:
- """Return error if registered on attempt to parse into _BookingLine."""
+ """Return error if registered on attempt to parse into BookingLine."""
return '; '.join(self.booked.errors) if self.booked else ''
@property
return self.raw.replace(' ', ' ')
-class _BookingLine(_Dictable):
+class BookingLine(_Dictable, ABC):
"""Parsed code part of a DatLine belonging to a _Booking."""
- def __init__(self) -> None:
- self.errors: list[str] = []
- self.idx = 0
+ def __init__(self, idx: int, errors: Optional[list[str]] = None) -> None:
+ self.idx = idx
+ self.errors: list[str] = errors if errors else []
+
+ @abstractmethod
+ def to_code(self) -> str:
+ """Parse to ledger file line code part."""
+ def to_dat_line(self, comment: str = '') -> DatLine:
+ """Make matching DatLine."""
+ return DatLine(self.to_code(), comment)
-class _IntroLine(_BookingLine):
+
+class IntroLine(BookingLine):
"""First line of a _Booking, expected to carry date etc."""
dictables = {'date', 'target'}
- def __init__(self, code: str) -> None:
- super().__init__()
- if code[0].isspace():
- self.errors += ['intro line indented']
- toks = code.lstrip().split(maxsplit=1)
- self.date = toks[0]
- self.target = toks[1] if len(toks) > 1 else ''
- if len(toks) == 1:
- self.errors += ['illegal number of tokens']
+ def __init__(
+ self,
+ date: str,
+ target: str,
+ errors: Optional[list[str]] = None
+ ) -> None:
+ super().__init__(0, errors)
+ self.target = target
+ self.date = date
try:
dt_date.fromisoformat(self.date)
except ValueError:
self.errors += [f'not properly formatted legal date: {self.date}']
+ @classmethod
+ def from_code(cls, code: str) -> Self:
+ """Parse from ledger file line code part."""
+ errors = []
+ if code[0].isspace():
+ errors += ['intro line indented']
+ toks = code.lstrip().split(maxsplit=1)
+ if len(toks) == 1:
+ errors += ['illegal number of tokens']
+ return cls(toks[0], toks[1] if len(toks) > 1 else '', errors)
-class _TransferLine(_BookingLine):
+ def to_code(self) -> str:
+ return f'{self.date} {self.target}'
+
+
+class TransferLine(BookingLine):
"""Non-first _Booking line, expected to carry value movement."""
dictables = {'amount', 'account', 'currency'}
- def __init__(self, code: str, idx: int) -> None:
- super().__init__()
- self.idx = idx
- self.currency = ''
- self.amount: Optional[Decimal] = None
+ def __init__(
+ self,
+ account: str,
+ amount: Optional[Decimal],
+ currency: str,
+ errors: Optional[list[str]] = None,
+ idx: int = -1
+ ) -> None:
+ super().__init__(idx, errors)
+ self.account = account
+ self.amount = amount
+ self.currency = currency
+
+ @classmethod
+ def from_code_at_idx(cls, code: str, idx: int) -> Self:
+ """Parse from ledger file line code part, assign in-Booking index."""
+ errors = []
+ currency: str = ''
+ amount: Optional[Decimal] = None
if not code[0].isspace():
- self.errors += ['transfer line not indented']
+ errors += ['transfer line not indented']
toks = code.lstrip().split()
- self.account = toks[0]
if len(toks) > 1:
- self.currency = toks[2] if 3 == len(toks) else '€'
+ currency = toks[2] if 3 == len(toks) else '€'
try:
- self.amount = Decimal(toks[1])
+ amount = Decimal(toks[1])
except DecimalInvalidOperation:
- self.errors += [f'improper amount value: {toks[1]}']
+ errors += [f'improper amount value: {toks[1]}']
if len(toks) > 3:
- self.errors += ['illegal number of tokens']
+ errors += ['illegal number of tokens']
+ return cls(toks[0], amount, currency, errors, idx)
+
+ def to_code(self) -> str:
+ code = f'{_DEFAULT_INDENT}{self.account}'
+ if self.amount:
+ code += f' {self.amount} {self.currency}'
+ return code
@property
def amount_short(self) -> str:
gap_lines: Optional[list[DatLine]] = None
) -> None:
self.next, self.prev = None, None
- self.id_, self.booked_lines = id_, booked_lines[:]
+ self.id_ = id_
+ self.booked_lines = booked_lines[:]
self._gap_lines = gap_lines[:] if gap_lines else []
- # parse booked_lines into Intro- and _TransferLines
+ # parse booked_lines into Intro- and TransferLines
for line in booked_lines:
line.booking = self
- self.intro_line = _IntroLine(self.booked_lines[0].code)
- self._transfer_lines = [_TransferLine(b_line.code, i+1) for i, b_line
- in enumerate(self.booked_lines[1:])]
+ self.intro_line = IntroLine.from_code(self.booked_lines[0].code)
+ self._transfer_lines = [
+ TransferLine.from_code_at_idx(b_line.code, i+1)
+ for i, b_line in enumerate(self.booked_lines[1:])]
self.booked_lines[0].booked = self.intro_line
for i, b_line in enumerate(self._transfer_lines):
self.booked_lines[i + 1].booked = b_line
@property
def gap_lines(self) -> list[DatLine]:
"""Return ._gap_lines or, if empty, list of one empty DatLine."""
- return self._gap_lines if self._gap_lines else [DatLine('')]
+ return self._gap_lines if self._gap_lines else [DatLine.new_empty()]
@gap_lines.setter
def gap_lines(self, gap_lines=list[DatLine]) -> None:
@property
def gap_lines_copied(self) -> list[DatLine]:
"""Return new DatLines generated from .raw's of .gap_lines."""
- return [DatLine(dat_line.raw) for dat_line in self.gap_lines]
+ return [dat_line.copy_unbooked() for dat_line in self.gap_lines]
@property
def booked_lines_copied(self) -> list[DatLine]:
"""Return new DatLines generated from .raw's of .booked_lines."""
- return [DatLine(dat_line.raw) for dat_line in self.booked_lines]
+ return [dat_line.copy_unbooked() for dat_line in self.booked_lines]
@property
def target(self) -> str:
"""(Re-)read ledger from file at ._path_dat."""
self.accounts, self.bookings, self.initial_gap_lines = {}, [], []
self.dat_lines: list[DatLine] = [
- DatLine(line)
+ DatLine.from_raw(line)
for line in self._path_dat.read_text(encoding='utf8').splitlines()]
self.last_save_hash = self._hash_dat_lines()
booked: list[DatLine] = []
gap_lines: list[DatLine] = []
booking: Optional[_Booking] = None
- for dat_line in self.dat_lines + [DatLine('')]:
+ for dat_line in self.dat_lines + [DatLine.new_empty()]:
if dat_line.code:
if gap_lines:
if booking:
if not gap_start_found: # end index is always after current line,
booked_end += 1 # provided we're not yet in the gap
elif line.code.strip():
- new_lines[i] = DatLine(f'; {line.code}')
+ new_lines[i] = DatLine.from_raw(f'; {line.code}')
before_gap = new_lines[:booked_start]
new_booked_lines = (new_lines[booked_start:booked_end]
if booked_start > -1 else [])
dat_lines_transaction: list[DatLine],
intro_comment: str = ''
) -> int:
+ intro = IntroLine(dt_date.today().isoformat(), target)
booking = _Booking(
len(self.bookings),
- [DatLine(f'{dt_date.today().isoformat()} {target}'
- + ' ; '.join([''] + [s for s in [intro_comment] if s]))
- ] + dat_lines_transaction)
+ [intro.to_dat_line(intro_comment)] + dat_lines_transaction)
self.bookings += [booking]
self._sync()
return booking.id_