usawa

Signed, immutable accounting.
Log | Files | Refs | Submodules | LICENSE

commit f8478853855bf8c2a8363dd0f8978f8f28456304
parent fc0eac13c7e88308a6de4e8c279b00be21ceee11
Author: Carlosokumu <carlosokumu254@gmail.com>
Date:   Tue, 24 Mar 2026 20:40:38 +0300

chore: restructure core classes

Diffstat:
Ddummy/usawa/core/__init__.py | 0
Ddummy/usawa/core/chain_manager.py | 158-------------------------------------------------------------------------------
Ddummy/usawa/core/entry_service.py | 70----------------------------------------------------------------------
Ddummy/usawa/core/models.py | 95-------------------------------------------------------------------------------
Ddummy/usawa/core/state_manager.py | 28----------------------------
5 files changed, 0 insertions(+), 351 deletions(-)

diff --git a/dummy/usawa/core/__init__.py b/dummy/usawa/core/__init__.py diff --git a/dummy/usawa/core/chain_manager.py b/dummy/usawa/core/chain_manager.py @@ -1,157 +0,0 @@ -import os -import logging -from usawa import Ledger, load -from usawa.crypto import ACL, DemoWallet -from usawa.store import LedgerStore -from whee.valkey import ValkeyStore -import os -import shutil -import logging - -from usawa import Ledger, load - -logg = logging.getLogger("core.chain_manager") - - -def default_chain_dir(): - """Return the conventional XDG data directory for usawa ledger files. - - Defaults to ~/.local/share/usawa/ledger/ unless XDG_DATA_HOME is set. - Directory is created if it does not exist. - """ - xdg_data = os.environ.get( - 'XDG_DATA_HOME', - os.path.join(os.path.expanduser('~'), '.local', 'share'), - ) - path = os.path.join(xdg_data, 'usawa', 'ledger') - os.makedirs(path, exist_ok=True) - return path - - -class LedgerChainManager: - - def __init__(self, genesis_path=None): - """Initialise the chain manager. - - On first ever launch, supply genesis_path so it can be copied into - the conventional directory as 0.xml. On subsequent launches, omit - genesis_path — the manager will reconstruct the chain from disk. - - :param genesis_path: Path to the genesis XML file (optional after first run) - :type genesis_path: str or None - """ - self.basedir = default_chain_dir() - logg.debug('chain dir: {}'.format(self.basedir)) - - zero = os.path.join(self.basedir, '0.xml') - if not os.path.exists(zero): - if genesis_path is None: - raise FileNotFoundError( - 'no chain found in {} and no genesis_path provided'.format(self.basedir) - ) - genesis_path = os.path.realpath(genesis_path) - if not os.path.exists(genesis_path): - raise FileNotFoundError('genesis file not found: {}'.format(genesis_path)) - shutil.copy(genesis_path, zero) - logg.debug('copied genesis {} → {}'.format(genesis_path, zero)) - - self.chain = self._reconstruct_chain() - logg.debug('chain reconstructed with depth {}'.format(self.depth())) - - - def _reconstruct_chain(self): - """Rebuild the chain list by scanning sequential xml files on disk. - - Starts at 0.xml and stops at the first missing index. - """ - chain = [] - index = 0 - while True: - path = os.path.join(self.basedir, '{}.xml'.format(index)) - if not os.path.exists(path): - break - chain.append(path) - index += 1 - if not chain: - raise FileNotFoundError('no chain files found in: {}'.format(self.basedir)) - return chain - - - def current(self): - """Return the path of the most recent XML file in the chain.""" - return self.chain[-1] - - - def derive_next(self): - """Derive the next output file path based on the current chain length. - - genesis = index 0, first entry output = index 1, and so on. - """ - next_index = len(self.chain) - basename = '{}.xml'.format(next_index) - return os.path.join(self.basedir, basename) - - - def advance(self, written_path): - """Call this after successfully writing an entry output file. - - Verifies the file exists before appending to the chain. - """ - written_path = os.path.realpath(written_path) - if not os.path.exists(written_path): - raise FileNotFoundError( - 'written file not found, cannot advance chain: {}'.format(written_path) - ) - self.chain.append(written_path) - logg.debug('chain advanced to: {}'.format(written_path)) - - - def load_current(self): - """Load and return a fresh Ledger instance from the current chain tail.""" - ledger_path = self.current() - ledger_tree = load(ledger_path) - ledger = Ledger.from_tree(ledger_tree) - logg.debug('loaded ledger from: {}'.format(ledger_path)) - return ledger - - - def write_entry(self, entry, ledger, store, wallet): - """Sign and write an entry, then advance the chain. - - Encapsulates the full write sequence: - entry.sign → store.add_entry → ledger.truncate → ledger.sign → write file - """ - next_path = self.derive_next() - - db = ValkeyStore('') - - store = LedgerStore(db, ledger) - pk = store.get_key() - wallet = DemoWallet(privatekey=pk) - logg.debug("wallet pk: %s pubk: %s", wallet.privkey().hex(), wallet.pubkey().hex()) - ledger.set_wallet(wallet) - - - ledger.acl = ACL.from_wallet(wallet) - self.ledger = ledger - - entry.sign(wallet) - store.add_entry(entry, update_ledger=True) - ledger.truncate() - ledger.sign() - - with open(next_path, 'wb') as f: - f.write(ledger.to_string()) - logg.debug('entry written to: {}'.format(next_path)) - - self.advance(next_path) - return next_path - - - def depth(self): - """Return the number of files in the chain including genesis.""" - return len(self.chain) - - - def __repr__(self): - return 'LedgerChainManager(depth={}, current={})'.format(self.depth(), self.current()) -\ No newline at end of file diff --git a/dummy/usawa/core/entry_service.py b/dummy/usawa/core/entry_service.py @@ -1,70 +0,0 @@ -import logging -from datetime import datetime -import uuid - -from .models import LedgerEntry -from usawa.storage.ledger_repository import LedgerRepository - -logg = logging.getLogger("core.entry_service") - - -class EntryService: - """Business logic for ledger entries""" - - def __init__(self, repository: LedgerRepository): - self.repository = repository - - def save_entry(self, entry: LedgerEntry) -> tuple[bool, str]: - try: - entry.tx_date = datetime.now() - entry.date_registered = datetime.now() - entry.transaction_ref = self._generate_transaction_ref() - - is_valid, error_msg = entry.validate() - if not is_valid: - logg.error(f"Entry validation failed: {error_msg}") - return False, error_msg - - self.repository.save(entry) - - logg.info(f"Entry saved successfully") - return True, "" - - except FileExistsError as e: - error_msg = ( - "Some file information for this entry is already recorded in the ledger" - ) - return False, error_msg - - except ValueError as e: - error_msg = f"Invalid entry data: {str(e)}" - logg.error(f"Validation error: {e}") - return False, error_msg - - except IOError as e: - error_msg = f"File error: {str(e)}" - logg.error(f"File operation failed: {e}") - return False, error_msg - - except Exception as e: - error_msg = f"Failed to save entry: {str(e)}" - logg.error(f"Unexpected error: {e}", exc_info=True) - return False, error_msg - - def save_wallet(self, wallet, passphrase): - return self.repository.save_wallet(wallet=wallet, passphrase=passphrase) - - def get_all_entries(self): - return self.repository.get_all_entries() - - def _generate_transaction_ref(self) -> str: - return str(uuid.uuid4()) - - def get_asset_bytes(self, digest: bytes) -> bytes: - return self.repository.get_asset_bytes(digest=digest) - - def export_all_entries_to_xml(self, output_path: str) -> tuple[bool, str]: - return self.repository.export_all_entries_to_xml(output_path=output_path) - - def export_entry_to_xml(self, serial: int, output_path: str) -> tuple[bool, str]: - return self.repository.export_entry_to_xml(serial, output_path) diff --git a/dummy/usawa/core/models.py b/dummy/usawa/core/models.py @@ -1,95 +0,0 @@ -from dataclasses import dataclass,field -from typing import Optional,List, Union -from datetime import datetime -from pathlib import Path - -@dataclass -class LedgerEntry: - """Ledger entry data model""" - - # Basic details - external_reference: Optional[str] = None - description: Optional[str] = None - - # Transaction details - amount: float = 0.0 - source_unit: str = "" - source_type: str = "" - source_path: str = "general" - dest_unit: str = "" - dest_type: str = "" - dest_path: str = "general" - - # Attachments - attachments: List[str] = field(default_factory=list) - - # Signers (public keys) - signer_pubkeys: List[str] = field(default_factory=list) - - serial: Optional[int] = None - tx_date: Optional[datetime] = None - tx_reference: Optional[str] = None - date_registered: Optional[datetime] = None - parent_digest: Optional[str] = None - unit_index: Optional[int] = None - - def validate(self) -> tuple[bool, str]: - """Validate entry data""" - if self.amount <= 0: - return False, "Amount must be greater than 0" - - if not self.source_unit or not self.dest_unit: - return False, "Unit/Currency is required for both source and destination" - - if not self.source_type or not self.dest_type: - return False, "Account type is required for both source and destination" - - if not self.source_path or not self.dest_path: - return False, "Account path is required for both source and destination" - - # Validate attachments - if self.attachments: - for filepath in self.attachments: - if not Path(filepath).exists(): - return False, f"Attachment file not found: {filepath}" - - return True, "" - - def __repr__(self): - return ( - f"LedgerEntry(" - f"external_reference={self.external_reference!r}, " - f"description={self.description!r}, " - f"serial={self.serial!r}, " - f"parent_digest={self.parent_digest}, " - f"amount={self.amount}, " - f"source_unit={self.source_unit}, " - f"tx_ref={self.tx_reference}, " - f"source_type={self.source_type}, " - f"dest_unit={self.dest_unit}, " - f"dest_type={self.dest_type})" - f"attachments={self.attachments!r})" - ) - - def add_attachment(self, filepath: Union[str, List[str]]): - """ - Add one or more attachment file paths - """ - if isinstance(filepath, str): - if filepath not in self.attachments: - self.attachments.append(filepath) - elif isinstance(filepath, list): - for path in filepath: - if path not in self.attachments: - self.attachments.append(path) - else: - raise TypeError(f"filepath must be str or List[str], got {type(filepath)}") - - def remove_attachment(self, filepath: str): - """Remove an attachment file path""" - if filepath in self.attachments: - self.attachments.remove(filepath) - - def get_attachment_count(self) -> int: - """Get number of attachments""" - return len(self.attachments) diff --git a/dummy/usawa/core/state_manager.py b/dummy/usawa/core/state_manager.py @@ -1,28 +0,0 @@ -import json -from pathlib import Path - -STATE_FILE = Path.home() / ".local" / "share" / "usawa" / "state.json" - - -class StateManager: - - @staticmethod - def get_state() -> dict: - if STATE_FILE.exists(): - return json.loads(STATE_FILE.read_text()) - return {} - - @staticmethod - def save_state(data: dict): - STATE_FILE.parent.mkdir(parents=True, exist_ok=True) - STATE_FILE.write_text(json.dumps(data, indent=2)) - - @staticmethod - def get(key: str, default=None): - return StateManager.get_state().get(key, default) - - @staticmethod - def set(key: str, value): - state = StateManager.get_state() - state[key] = value - StateManager.save_state(state)