commit 0a1a073f082e51594e6e89f920288506eaccc929
parent 7313df604c86ea1f1666a56b9cd8f97b692a5e1e
Author: lash <dev@holbrook.no>
Date: Sat, 4 Apr 2026 09:55:56 -0600
Factor out interactive code
Diffstat:
4 files changed, 307 insertions(+), 373 deletions(-)
diff --git a/dummy/usawa/cli/entry.py b/dummy/usawa/cli/entry.py
@@ -0,0 +1,274 @@
+import uuid
+import datetime
+import logging
+
+from usawa import Asset, Entry, EntryPart
+from usawa.account import Account, AccountDisplay
+
+logg = logging.getLogger('cli.entry')
+
+
+def choose_account(ctx, include_create=False):
+ r = None
+ accounts_r = []
+ while not r:
+ a = None
+ if len(accounts_r) == 0:
+ a = input('enter account: ')
+ if a == 'q':
+ raise AbortMenu()
+ elif len(accounts_r) == 1:
+ r = accounts_r[0]
+ break
+ else:
+ v = input('choose match: ')
+ i = None
+ if v == 'q':
+ raise AbortMenu()
+ try:
+ i = int(v)
+ except ValueError:
+ logg.debug('Invalid number, going back to search')
+ if i != None:
+ try:
+ r = accounts_r[i]
+ except IndexError:
+ logg.error('Number out of range')
+ continue
+ accounts_r = []
+ ctx.aidx.set_filter(path=a)
+ i = 0
+ for a in ctx.aidx:
+ accounts_r.append(a)
+ print('{} {}'.format(i, a))
+ i += 1
+ if i == 0:
+ logg.debug('no accounts found')
+ ctx.aidx.reset_filter()
+ return r
+
+
+def parse_unit(ctx, v):
+ return ctx.uidx.sym(v)
+
+
+def parse_amount(ctx, sym, v):
+ return ctx.uidx.from_floatstring(sym, v)
+
+
+def parse_side(ctx, v):
+ for k in ['src', 'dst']:
+ if k.startswith(v):
+ return k
+ raise ValueError('invalid side: ' + v)
+
+
+def parse_txdate(ctx, v):
+ return datetime.date.fromisoformat(v)
+
+
+def handle_tag(ctx, entry, v):
+ r = input('Tag: ')
+ if v == '+':
+ entry.tag(r)
+ elif v == '-':
+ entry.untag(r)
+
+
+
+
+
+def input_or_default(prompt, default=None, postfix=': ', validate_fn=None):
+ if default != None:
+ postfix = ' [{}]'.format(default) + postfix
+ v = input(prompt + postfix)
+ if len(v) == 0:
+ if default == None:
+ raise ValueError('empty value and no default')
+ v = default
+ if validate_fn != None:
+ validate_fn(v)
+ return v
+
+
+def try_entry_uuid(ctx, v):
+ v = uuid.UUID(v)
+ entry = Entry.empty(ref=str(v))
+ return ctx.store.get_draft(entry)
+
+
+def try_entry_serial(ctx, v):
+ v = int(v)
+ entry = Entry(v, None)
+ return ctx.store.get_entry(entry)
+
+
+def try_entry_digest(ctx, k):
+ if isinstance(k, str):
+ k = bytes.fromhex(k)
+ if len(k) != 64:
+ raise ValueError('invalid digest length')
+ v = ctx.resolver.get(k)
+ return Entry.from_string(v, ctx.uidx)
+
+
+def try_entry(ctx, entry_spec):
+ if not entry_spec:
+ return Entry.empty(unitindex=ctx.uidx)
+
+ try:
+ return try_entry_uuid(ctx, entry_spec)
+ except ValueError:
+ pass
+
+ try:
+ return try_entry_serial(ctx, entry_spec)
+ except ValueError:
+ pass
+
+ try:
+ return try_entry_digest(ctx, entry_spec)
+ except ValueError:
+ pass
+ return None
+
+
+class EntrySession:
+
+ def __init__(self, ctx, entry_spec=None):
+ self.ctx = ctx
+ self.unitbase = ctx.uidx.base
+ self.commit = False
+ self.ref = None
+ self.dt = None
+ self.part_side = 'src'
+ self.have_src = False
+ self.have_dst = False
+ self.entry = try_entry(self.ctx, entry_spec)
+ if self.entry.serial > 0:
+ raise NotImplementedError('entry edit not yet implemented')
+ self._do_prepare()
+
+
+ def _do_prepare(self):
+ uu = uuid.uuid4()
+ self.ref = str(uu)
+ dt = datetime.datetime.now(datetime.UTC)
+ self.dt = dt
+
+
+ def attach_ref(self, v):
+ k = uuid.UUID(v)
+ asset = Asset(ref=str(k))
+ asset = self.ctx.store.get_asset_indexed(asset)
+ self.entry.attach(asset)
+
+
+ def attach_digest(self, v):
+ k = bytes.fromhex(v)
+ asset = Asset(digest=k)
+ asset = self.ctx.store.get_asset(asset)
+ self.entry.attach(asset)
+
+
+ def enter_part(self, side=None):
+ if side != None:
+ self.part_side = side
+ elif len(self.entry.debit) > 0:
+ if self.have_dst:
+ self.part_side = 'dst'
+ else:
+ self.part_side = 'src'
+ v = input_or_default('Entry side', self.side)
+ if v == '':
+ return False
+ k = parse_side(ctx, v)
+ self.part_side = k
+
+ #v = input_or_default('Entry {} account'.format(ctx.get('partk')))
+ #account = parse_account(ctx, v, sym=unit, typ=typ)
+ v = choose_account(self.ctx)
+ account = Account.from_path(v)
+
+ #amount = None
+ v = input_or_default('Account {} amount'.format(account))
+ amount = parse_amount(self.ctx, account.sym, v)
+
+ isdebit = self.part_side=='src'
+ part = EntryPart(account.sym, account.typ.value.lower(), account.to_path(display=AccountDisplay.path), amount, debit=isdebit)
+ self.entry.add_part(part)
+
+ if self.part_side == 'src':
+ self.have_src = True
+
+ if self.part_side == 'dst':
+ self.have_dst = True
+
+ return True
+
+
+ def handle_input(self, v):
+ if v == 'i' or v == 'o':
+ k = 'src'
+ if v == 'o':
+ k = 'dst'
+ self.enter_part(side=k)
+ return True
+ if v == 'q':
+ raise StopIteration()
+ if v == '+' or v == '-':
+ r = handle_tag(self.ctx, self.entry, v)
+ return True
+ if v == 'w':
+ self.commit = True
+ return False
+ if v == 't':
+ return False
+ logg.error('invalid input')
+ return True
+
+
+ def do_interactive_one(self):
+ v = input_or_default('Entry description', self.entry.description)
+ self.entry.description = v
+ v = input_or_default('External ref', self.entry.ref)
+ self.entry.ref = v
+ v = input_or_default('Transaction date(time)', self.entry.dt)
+ if isinstance(v, str):
+ v = parse_txdate(self.ctx, v)
+ self.entry.dt = v
+
+
+ def do_interactive_two(self):
+ r = True
+ while r:
+ v = input("> ")
+ r = self.handle_input(v)
+
+
+ def finalize(self):
+ if self.commit:
+ self.entry.parent = self.ctx.ledger.cur
+ self.entry.serial = self.ctx.ledger.next_serial()
+ self.entry.sign(self.ctx.wallet)
+ self.ctx.store.add_entry(self.entry, update_ledger=True)
+ self.ctx.ledger.truncate()
+ self.ctx.ledger.sign()
+ f = open(self.ctx.ledger_path_out, 'w')
+ f.write(self.ctx.ledger.to_string())
+ f.close()
+ if self.ctx.resolver != None:
+ self.ctx.resolver.put_entry(self.entry, lookup='sha512')
+ else:
+ self.ctx.store.put_draft(self.entry)
+ return True
+
+
+ def start(self):
+ self.do_interactive_one()
+ try:
+ self.do_interactive_two()
+ except StopIteration:
+ return False
+ r = self.finalize()
+ return self.entry
diff --git a/dummy/usawa/context.py b/dummy/usawa/context.py
@@ -147,7 +147,7 @@ class UsawaContext:
if store_scope == 'ledger':
self.store = LedgerStore(self.db, self.ledger)
self.keystore = self.store
- elif store_scope == 'asset' or store_score == 'entry':
+ elif store_scope == 'asset' or store_scope == 'entry':
self.keystore = KeyStore(self.db)
if store_scope == 'asset':
self.store = AssetStore(self.db)
diff --git a/dummy/usawa/entry.py b/dummy/usawa/entry.py
@@ -182,7 +182,7 @@ class Entry(UsawaElement):
:todo: Prevent changes after the first signature calculation.
:todo: Ensure canonical format of keyid.
"""
- def __init__(self, serial, tx_date, ref=None, description=None, parent=None, tx_datereg=None, unitindex=None, tags=None):
+ def __init__(self, serial, tx_date, ref=None, description=None, parent=None, tx_datereg=None, unitindex=None, tags=None, extref=None):
super(Entry, self).__init__(ref=ref)
if isinstance(parent, str):
parent = bytes.fromhex(parent)
@@ -209,22 +209,23 @@ class Entry(UsawaElement):
self.parts = []
self.balancer = None
self.tags = tags
+ self.extref = extref
if unitindex != None:
self.balancer = Balancer(unitindex)
@staticmethod
- def empty(tx_date=None, ref=None, description=None, parent=None, tx_datereg=None, unitindex=None, tags=None, serial=-1):
+ def empty(tx_date=None, ref=None, description=None, parent=None, tx_datereg=None, unitindex=None, tags=None, serial=-1, extref=None):
if not tx_date:
tx_date = datetime.datetime.utcnow()
logg.debug('txdate is {}'.format(tx_date))
- return Entry(serial, tx_date, ref=ref, description=description, parent=parent, tx_datereg=tx_datereg, unitindex=unitindex, tags=tags)
+ return Entry(serial, tx_date, ref=ref, description=description, parent=parent, tx_datereg=tx_datereg, unitindex=unitindex, tags=tags, extref=extref)
def clone(self, unitindex=None, description=None, invert_parts=False, include_parts=True):
if description == None:
description = self.description + ' (clone)'
- o = Entry(-1, self.dt, ref=self.ref, description=self.description, unitindex=unitindex, tags=self.tags)
+ o = Entry(-1, self.dt, ref=self.ref, description=self.description, unitindex=unitindex, tags=self.tags, extref=self.extref)
if include_parts:
for v in self.debit:
if invert_parts:
diff --git a/dummy/usawa/runnable/entry.py b/dummy/usawa/runnable/entry.py
@@ -18,345 +18,27 @@ from usawa.store import LedgerStore
from usawa.resolve.fs import FSResolver
from usawa.account import Account, AccountIndex, AccountType, AccountDisplay
from usawa.constant import CATEGORIES
+from usawa.cli.entry import EntrySession
logging.basicConfig(level=logging.WARNING)
logg = logging.getLogger()
-def try_entry_uuid(ctx, v):
- v = uuid.UUID(v)
- entry = Entry.empty(ref=str(v))
- return ctx.store.get_draft(entry)
-
-
-def try_entry_serial(ctx, v):
- v = int(v)
- entry = Entry(v, None)
- return ctx.store.get_entry(entry)
-
-
-def try_entry_digest(ctx, k):
- if isinstance(k, str):
- k = bytes.fromhex(k)
- if len(k) != 64:
- raise ValueError('invalid digest length')
- v = ctx.resolver.get(k)
- return Entry.from_string(v, ctx.uidx)
-
-
-def try_entry(ctx, args):
- if not args.entry:
- return Entry.empty(unitindex=ctx.uidx)
-
- try:
- return try_entry_uuid(ctx, args.entry)
- except ValueError:
- pass
-
- try:
- return try_entry_serial(ctx, args.entry)
- except ValueError:
- pass
-
- try:
- return try_entry_digest(ctx, args.entry)
- except ValueError:
- pass
-
- return None
-
-
-#class Context:
-#
-# def __init__(self, args):
-# self.cfg = usawa.config.load_config(config_dir=args.c)
-# self.state = 0
-# self.commit = args.commit
-#
-# # entry parts
-# self.description = None
-# self.src = []
-# self.dst = []
-# self.amount = None
-# self.output = None
-# self.f = None
-# if args.t:
-# self.txdate = datetime.datetime.fromisoformat(args.t)
-# else:
-# self.txdate = datetime.datetime.now(datetime.UTC)
-# self.attach = []
-#
-# # set up ledger
-# s = args.ledger_file
-# if not s:
-# try:
-# s = self.cfg.get('MAIN_LEDGER_FILE')
-# except KeyError:
-# pass
-# if not s:
-# raise ValueError('ledger file required')
-# self.ledger = Ledger.from_file(s)
-# self.fp = args.o
-# self.fp_bak = None
-# if self.fp == None:
-# self.fp = s
-# self.fp_bak = s + '_' + str(self.ledger.serial)
-# self.uidx = self.ledger.uidx
-#
-# # set up accounts hierarchy, if applicable
-# self.accounts = None
-# s = self.cfg.get('ACCOUNTS_FILE')
-# if s:
-# self.accounts = AccountIndex.from_file(self.uidx, s)
-# else:
-# self.accounts = AccountIndex(self.uidx)
-# if self.cfg.true('ACCOUNTS_STRICT'):
-# self.accounts.lock()
+#def parse_type(ctx, v):
+# v = v.lower()
+# r = None
+# for k in CATEGORIES:
+# if k.startswith(v):
+# r = k
+# logg.info("expanded input '{}' to category {}".format(v, r))
+# break
+# if not r:
+# raise ValueError('invalid type: ' + v)
+# o = getattr(AccountType, r)
+# logg.debug('accounttype {}'.format(o))
+# return o
#
-# self.entry = Entry(-1, self.txdate, ref=args.e)
-# self.db = None
-# if self.cfg.get('STORE_TYPE') == 'valkey':
-# dbid = self.cfg.get('VALKEY_ID')
-# host = self.cfg.get('VALKEY_HOST')
-# port = self.cfg.get('VALKEY_PORT')
-# self.db = ValkeyStore('', host=host, port=port)
-# elif self.cfg.get('STORE_TYPE') == 'fs':
-# base = self.cfg.get('FSSTORE_BASE')
-# self.db = FsStore(base=base, dbname='usawa')
-# self.store = LedgerStore(self.db, self.ledger)
-# ops = int(self.cfg.get('WALLET_OPSLIMIT', 0))
-# mem = int(self.cfg.get('WALLET_MEMLIMIT', 0))
-# pw = self.cfg.get('WALLET_KEY_PASSPHRASE')
-# if args.p and pw == None:
-# pw = getpass.getpass("passphrase: ")
-# self.wallet = self.store.get_key(DemoWallet, passphrase=pw, opslimit=ops, memlimit=mem)
-# self.ledger.set_wallet(self.wallet)
-# if args.e:
-# self.entry = self.store.get_draft(self.entry)
-# self.state = 1
-# else:
-# self.store.put_draft(self.entry)
-# self.ref = self.entry.get_ref()
#
-# self.base = self.uidx.base
-# self.k = 'src'
-# self.havedst = False
-# self.i = 0
-# self.resolver = None
-# resolver_path = self.cfg.get('FS_RESOLVER_STORE_PATH', args.r)
-# if resolver_path != None:
-# self.resolver = FSResolver(os.path.realpath(resolver_path))
-#
-
-def parse_type(ctx, v):
- v = v.lower()
- r = None
- for k in CATEGORIES:
- if k.startswith(v):
- r = k
- logg.info("expanded input '{}' to category {}".format(v, r))
- break
- if not r:
- raise ValueError('invalid type: ' + v)
- o = getattr(AccountType, r)
- logg.debug('accounttype {}'.format(o))
- return o
-
-
-def parse_unit(ctx, v):
- return ctx.uidx.sym(v)
-
-
-#def parse_account(ctx, v, typ, sym):
-# account = self.accounts.check(sym, typ, v)
-# if account:
-# account = Account.from_path(account)
-# else:
-# account = self.accounts.add(v, sym=sym, typ=typ)
-# return account
-
-
-def parse_amount(ctx, sym, v):
- return ctx.uidx.from_floatstring(sym, v)
-
-
-def parse_side(ctx, v):
- for k in ['src', 'dst']:
- if k.startswith(v):
- return k
- raise ValueError('invalid side: ' + v)
-
-
-def choose_account(ctx, include_create=False):
- r = None
- accounts_r = []
- while not r:
- a = None
- if len(accounts_r) == 0:
- a = input('enter account: ')
- if a == 'q':
- raise AbortMenu()
- elif len(accounts_r) == 1:
- r = accounts_r[0]
- break
- else:
- v = input('choose match: ')
- i = None
- if v == 'q':
- raise AbortMenu()
- try:
- i = int(v)
- except ValueError:
- logg.debug('Invalid number, going back to search')
- if i != None:
- try:
- r = accounts_r[i]
- except IndexError:
- logg.error('Number out of range')
- continue
- accounts_r = []
- ctx.aidx.set_filter(path=a)
- i = 0
- for a in ctx.aidx:
- accounts_r.append(a)
- print('{} {}'.format(i, a))
- i += 1
- if i == 0:
- logg.debug('no accounts found')
- ctx.aidx.reset_filter()
- return r
-
-
-def add_part(self, part):
- logg.info('add part {}'.format(part))
- self.part.append(part)
-
-
-def validate(self):
- if len(self.src) == 0:
- raise ValueError('no src')
- if len(self.dst) == 0:
- raise ValueError('no dst')
- if self.ref == None:
- raise ValueError('invalid ref')
-
-
-def parse_txdate(ctx, v):
- return datetime.date.fromisoformat(v)
-
-
-def input_or_default(prompt, default=None, postfix=': ', validate_fn=None):
- if default != None:
- postfix = ' [{}]'.format(default) + postfix
- v = input(prompt + postfix)
- if len(v) == 0:
- if default == None:
- raise ValueError('empty value and no default')
- v = default
- if validate_fn != None:
- validate_fn(v)
- return v
-
-
-def do_interactive_one(ctx):
- ctx.set('havesrc', False)
- ctx.set('havedst', False)
- ctx.set('partk', 'src')
- v = input_or_default('Entry description', ctx.get('description'))
- ctx.set('description', v)
- v = input_or_default('External ref', ctx.get('ref'))
- ctx.set('ref', v)
- v = input_or_default('Transaction date(time)', ctx.get('dt'))
- if isinstance(v, str):
- v = parse_txdate(ctx, v)
- ctx.set('dt', v)
-
-
-def handle_tag(ctx, entry, v):
- r = input('Tag: ')
- if v == '+':
- entry.tag(r)
- elif v == '-':
- entry.untag(r)
-
-
-def handle_input(ctx, entry, v):
- if v == 'i' or v == 'o':
- k = 'src'
- if v == 'o':
- k = 'dst'
- enter_part(ctx, entry, side=k)
- return True
- if v == 'q':
- raise StopIteration()
- if v == '+' or v == '-':
- r = handle_tag(ctx, entry, v)
- return True
- if v == 'w':
- ctx.set('commit', True)
- return False
- if v == 't':
- return False
- logg.error('invalid input')
- return True
-
-
-def do_interactive_two(ctx, entry):
- r = True
- while r:
-# try:
-# r = enter_part(ctx, entry)
-# except Exception as e:
-# logg.error('err {}'.format(e))
-# pass
- v = input("> ")
- r = handle_input(ctx, entry, v)
-
-
-def enter_part(ctx, entry, side=None):
- if side != None:
- ctx.set('partk', side)
- elif len(entry.debit) > 0:
- if ctx.get('havedst'):
- ctx.set('partk', 'dst')
- else:
- ctx.set('partk', 'src')
- v = input_or_default('Entry side', ctx.get('partk'))
- if v == '':
- return False
- k = parse_side(ctx, v)
- ctx.set('partk', k)
-
- #v = input_or_default('Entry {} account'.format(ctx.get('partk')))
- #account = parse_account(ctx, v, sym=unit, typ=typ)
- v = choose_account(ctx)
- account = Account.from_path(v)
-
- #amount = None
- k = ctx.get('partk')
- v = input_or_default('Account {} amount'.format(account))
- amount = parse_amount(ctx, account.sym, v)
-
- isdebit = k=='src'
- part = EntryPart(account.sym, account.typ.value.lower(), account.to_path(display=AccountDisplay.path), amount, debit=isdebit)
- logg.debug('addpart {}'.format(part))
- entry.add_part(part)
-
- if ctx.get('partk') == 'src':
- ctx.set('havesrc', True)
-
- if ctx.get('partk') == 'dst':
- ctx.set('havedst', True)
-
- return True
-
-
-def do_prepare(ctx, entry=None):
- uu = uuid.uuid4()
- ctx.set('ref', str(uu))
- dt = datetime.datetime.now(datetime.UTC)
- ctx.set('dt', dt)
-
def main():
def croak(*args, **kwargs):
@@ -392,55 +74,32 @@ def main():
ctx.set('unitbase', ctx.uidx.base)
ctx.set('commit', False)
- entry = try_entry(ctx, args)
- if entry.serial > 0:
- raise NotImplementedError('entry edit not yet implemented')
- do_prepare(ctx, entry=entry)
+# entry = try_entry(ctx, args)
+# if entry.serial > 0:
+# raise NotImplementedError('entry edit not yet implemented')
+# do_prepare(ctx, entry=entry)
# entry = Entry.empty(ref=ctx.get('ref'), unitindex=ctx.uidx, tx_date=ctx.get('dt'))
#entry = ctx.store.get_draft(entry)
#entry.description = ctx.get('description')
#entry.dt = ctx.txdate
- for v in args.x:
- k = uuid.UUID(v)
- asset = Asset(ref=str(k))
- asset = ctx.store.get_asset_indexed(asset)
- entry.attach(asset)
- for v in args.z:
- k = bytes.fromhex(v)
- asset = Asset(digest=k)
- asset = ctx.store.get_asset(asset)
- entry.attach(asset)
+ o = EntrySession(ctx, args.entry)
- do_interactive_one(ctx)
- try:
- do_interactive_two(ctx, entry)
- except StopIteration:
- sys.exit(0)
+ for v in args.x:
+ o.attach_ref(v)
+ for v in args.z:
+ o.attach_digest(v)
+
+ entry = o.start()
+
# if ctx.commit:
# if entry.serial != -1:
# raise AttributeError('entry draft already marked as committed')
# entry.serial = ctx.ledger.next_serial()
# v = input_or_default('Commit? (type YES, any other input is no)', '')
- if ctx.get('commit'):
- entry.parent = ctx.ledger.cur
- entry.serial = ctx.ledger.next_serial()
- entry.sign(ctx.wallet)
- ctx.store.add_entry(entry, update_ledger=True)
- ctx.ledger.truncate()
- ctx.ledger.sign()
-# if ctx.fp_bak != None:
-# shutil.copy(ctx.fp, ctx.fp_bak)
- f = open(ctx.ledger_path_out, 'w')
- f.write(ctx.ledger.to_string())
- f.close()
- if ctx.resolver != None:
- ctx.resolver.put_entry(entry, lookup='sha512')
- else:
- ctx.store.put_draft(entry)
-
+
print(entry)