# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : workspace.py # Description : AIR home — a managed local data directory: ledger, archived documents, exports. # ============================================================================= """The AIR home: managed local data for standalone users. Users without any third-party system keep everything in one directory: / ├── meta.json # company name, default policy set, created date ├── ledger.jsonl # the hash-chained book of record ├── documents/ # every AIR document ever posted (content-addressed) └── exports/ # generated CSV/JSON exports and reports Documents are archived content-addressed (sha256 prefix + original name), so the exact input of every posting can be replayed later — pairing with the ledger's idempotency keys and the compiler's determinism, the entire book of record is reproducible from this directory alone. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass from pathlib import Path from kernel.ledger import Ledger META_FILE = "meta.json" class WorkspaceError(RuntimeError): pass @dataclass class Workspace: root: Path # -- lifecycle ----------------------------------------------------------- @classmethod def init( cls, root: str | Path, *, name: str = "my-books", policies: str | None = None, created: str | None = None, ) -> "Workspace": root = Path(root) if (root / META_FILE).exists(): raise WorkspaceError(f"workspace already initialized: {root}") (root / "documents").mkdir(parents=True, exist_ok=True) (root / "exports").mkdir(parents=True, exist_ok=True) meta = { "_author": "Simon-Pierre Boucher ", "air_home_version": "1", "name": name, "policies": policies, "created": created, } (root / META_FILE).write_text(json.dumps(meta, indent=2), encoding="utf-8") (root / "ledger.jsonl").touch() return cls(root=root) @classmethod def open(cls, root: str | Path) -> "Workspace": root = Path(root) if not (root / META_FILE).exists(): raise WorkspaceError( f"no AIR home at {root} — run: air init --home {root} " "--policies " ) return cls(root=root) # -- accessors ------------------------------------------------------------- @property def meta(self) -> dict: return json.loads((self.root / META_FILE).read_text(encoding="utf-8")) @property def ledger_path(self) -> Path: return self.root / "ledger.jsonl" @property def exports_dir(self) -> Path: return self.root / "exports" def ledger(self) -> Ledger: return Ledger(path=self.ledger_path) def default_policies(self) -> str | None: return self.meta.get("policies") # -- data management ----------------------------------------------------------- def archive_document(self, path: str | Path) -> Path: """Store a posted AIR document content-addressed; idempotent.""" path = Path(path) content = path.read_bytes() digest = hashlib.sha256(content).hexdigest()[:12] target = self.root / "documents" / f"{digest}_{path.name}" if not target.exists(): target.write_bytes(content) return target def save_export(self, filename: str, content: str) -> Path: target = self.exports_dir / filename target.write_text(content, encoding="utf-8") return target def status(self) -> dict: ledger = self.ledger() documents = sorted((self.root / "documents").glob("*")) exports = sorted(self.exports_dir.glob("*")) return { "home": str(self.root), "name": self.meta.get("name"), "policies": self.meta.get("policies"), "entries": len(ledger.entries), "chain_valid": ledger.verify_chain(), "documents_archived": len(documents), "exports": len(exports), }