Source code for finamt.storage.project

"""
finamt.storage.project
~~~~~~~~~~~~~~~~~~~~~~~~~
Project layout resolution — maps a project name to its three paths:

  ~/.finamt/<project>/finamt.db   — SQLite database
  ~/.finamt/<project>/pdfs/          — original PDF archive
  ~/.finamt/<project>/debug/         — per-receipt agent debug output

Usage::

    from finamt.storage.project import resolve_project, layout_from_db_path

    layout = resolve_project()                   # uses "default" or FINAMT_PROJECT env var
    layout = resolve_project("acme-gmbh-2025")   # explicit project name
    layout = layout_from_db_path(Path("..."))    # reverse: infer layout from db path

Migration note (breaking change in 0.x)
----------------------------------------
Previous versions stored data at ``~/.finamt/finamt.db`` (flat layout).
The new structure requires data to live under a project subfolder.
To migrate an existing installation::

    mkdir -p ~/.finamt/default
    mv ~/.finamt/finamt.db ~/.finamt/default/
    mv ~/.finamt/pdfs         ~/.finamt/default/
    mv ~/.finamt/debug        ~/.finamt/default/
"""

from __future__ import annotations

import os
import re
from dataclasses import dataclass
from pathlib import Path

FINAMT_HOME = Path.home() / ".finamt"
DEFAULT_PROJECT = "default"
DB_FILENAME = "finamt.db"

# Project names: lowercase alphanumeric + hyphens + underscores, 1–64 chars
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")


[docs] @dataclass(frozen=True) class ProjectLayout: """All paths belonging to a single project.""" name: str root: Path # ~/.finamt/<name>/ db_path: Path # root/finamt.db pdfs_dir: Path # root/pdfs/ debug_dir: Path # root/debug/
[docs] def create_dirs(self) -> None: """Ensure all project directories exist.""" self.root.mkdir(parents=True, exist_ok=True) self.pdfs_dir.mkdir(parents=True, exist_ok=True) self.debug_dir.mkdir(parents=True, exist_ok=True)
@property def is_default(self) -> bool: return self.name == DEFAULT_PROJECT @property def exists(self) -> bool: """True if the db file has been created.""" return self.db_path.exists()
[docs] def resolve_project( project: str | None = None, *, env_var: bool = True, ) -> ProjectLayout: """ Resolve a project name to its layout. Priority order: 1. Explicit ``project`` argument 2. ``FINAMT_PROJECT`` environment variable (when env_var=True) 3. ``"default"`` """ name = project or (os.environ.get("FINAMT_PROJECT") if env_var else None) or DEFAULT_PROJECT return _make_layout(name)
[docs] def layout_from_db_path(db_path: Path) -> ProjectLayout: """ Infer a ProjectLayout from an explicit db path. If the db lives at ``~/.finamt/<name>/finamt.db`` the project name is taken from the containing directory. Any other path uses the db file's stem as the project name, rooting everything in the db's parent directory. """ db_path = db_path.resolve() parent = db_path.parent if parent.parent == FINAMT_HOME and db_path.name == DB_FILENAME: name = parent.name else: name = db_path.stem return ProjectLayout( name=name, root=parent, db_path=db_path, pdfs_dir=parent / "pdfs", debug_dir=parent / "debug", )
[docs] def validate_project_name(name: str) -> str | None: """ Validate a proposed project name. Returns an error message string on failure, None on success. """ if not name or not name.strip(): return "Name cannot be empty." if not _NAME_RE.match(name): return ( "Use only lowercase letters, digits, hyphens and underscores. " "Must start with a letter or digit (max 64 characters)." ) return None
[docs] def list_projects() -> list[ProjectLayout]: """ Scan FINAMT_HOME for project subdirectories. Returns layouts sorted: default first, then alphabetically. """ if not FINAMT_HOME.exists(): return [] layouts = [] for subdir in sorted(FINAMT_HOME.iterdir()): if not subdir.is_dir(): continue db = subdir / DB_FILENAME layouts.append( ProjectLayout( name=subdir.name, root=subdir, db_path=db, pdfs_dir=subdir / "pdfs", debug_dir=subdir / "debug", ) ) # default first layouts.sort(key=lambda p: (0 if p.is_default else 1, p.name)) return layouts
# --------------------------------------------------------------------------- # Module-level default (used by sqlite.py and agent.py as the fallback) # --------------------------------------------------------------------------- def _make_layout(name: str) -> ProjectLayout: root = FINAMT_HOME / name return ProjectLayout( name=name, root=root, db_path=root / DB_FILENAME, pdfs_dir=root / "pdfs", debug_dir=root / "debug", ) __all__ = [ "FINAMT_HOME", "DEFAULT_PROJECT", "ProjectLayout", "resolve_project", "layout_from_db_path", "validate_project_name", "list_projects", ]