Skip to content

tumbler.persistence

tumbler.persistence

YAML persistence for tumbler plans.

Plans live under <data_dir>/schedules/<wallet_name>.yaml. The file is the canonical source of truth for an in-progress tumble; the runner overwrites it on every state transition and reads it on startup to resume.

Attributes

SCHEDULES_SUBDIR = 'schedules' module-attribute

Classes

PlanCorruptError

Bases: ValueError

Raised when an on-disk plan cannot be parsed or validated.

Source code in tumbler/src/tumbler/persistence.py
28
29
class PlanCorruptError(ValueError):
    """Raised when an on-disk plan cannot be parsed or validated."""

PlanNotFoundError

Bases: FileNotFoundError

Raised when no plan exists for the given wallet.

Source code in tumbler/src/tumbler/persistence.py
24
25
class PlanNotFoundError(FileNotFoundError):
    """Raised when no plan exists for the given wallet."""

Functions:

delete_plan(wallet_name: str, data_dir: Path | str | None = None) -> bool

Remove a wallet's plan file. Returns True if a file was removed, False if no plan existed.

Source code in tumbler/src/tumbler/persistence.py
108
109
110
111
112
113
114
115
116
117
118
def delete_plan(wallet_name: str, data_dir: Path | str | None = None) -> bool:
    """
    Remove a wallet's plan file. Returns ``True`` if a file was removed,
    ``False`` if no plan existed.
    """
    target = plan_path(wallet_name, data_dir)
    try:
        target.unlink()
    except FileNotFoundError:
        return False
    return True

load_plan(wallet_name: str, data_dir: Path | str | None = None) -> Plan

Load a plan for wallet_name. Raises if missing or corrupt.

Source code in tumbler/src/tumbler/persistence.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def load_plan(wallet_name: str, data_dir: Path | str | None = None) -> Plan:
    """Load a plan for ``wallet_name``. Raises if missing or corrupt."""
    target = plan_path(wallet_name, data_dir)
    if not target.exists():
        raise PlanNotFoundError(f"no tumbler plan found for wallet {wallet_name!r}")
    try:
        raw = yaml.safe_load(target.read_text(encoding="utf-8"))
    except yaml.YAMLError as exc:
        raise PlanCorruptError(f"plan at {target} is not valid YAML: {exc}") from exc
    if not isinstance(raw, dict):
        raise PlanCorruptError(f"plan at {target} is not a mapping")
    try:
        return Plan.model_validate(raw)
    except ValidationError as exc:
        raise PlanCorruptError(f"plan at {target} failed schema validation: {exc}") from exc

plan_path(wallet_name: str, data_dir: Path | str | None = None) -> Path

Return the absolute path where wallet_name's plan is stored.

Source code in tumbler/src/tumbler/persistence.py
44
45
46
47
48
49
50
51
52
53
54
55
56
def plan_path(wallet_name: str, data_dir: Path | str | None = None) -> Path:
    """Return the absolute path where ``wallet_name``'s plan is stored."""
    if not wallet_name:
        raise ValueError("wallet_name must be non-empty")
    if "/" in wallet_name or "\\" in wallet_name or wallet_name in {".", ".."}:
        raise ValueError(f"wallet_name is not a safe filename: {wallet_name!r}")
    # Strip a trailing ".jmdat" if the caller passes the full file name; the
    # plan file is keyed by the wallet's stem so both spellings land at the
    # same path.
    stem = wallet_name
    if stem.endswith(".jmdat"):
        stem = stem[: -len(".jmdat")]
    return _schedules_dir(data_dir) / f"{stem}.yaml"

save_plan(plan: Plan, data_dir: Path | str | None = None) -> Path

Atomically write plan to disk and return the path.

The write is atomic against concurrent readers: we write to a sibling temp file in the same directory and then os.replace it.

Source code in tumbler/src/tumbler/persistence.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def save_plan(plan: Plan, data_dir: Path | str | None = None) -> Path:
    """
    Atomically write ``plan`` to disk and return the path.

    The write is atomic against concurrent readers: we write to a sibling
    temp file in the same directory and then ``os.replace`` it.
    """
    plan.touch()
    target = plan_path(plan.wallet_name, data_dir)
    payload = plan.model_dump(mode="json")
    # ``sort_keys=False`` preserves field order so the file stays readable.
    serialized = yaml.safe_dump(payload, sort_keys=False, default_flow_style=False)

    directory = target.parent
    fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=directory)
    tmp_path = Path(tmp_name)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(serialized)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(tmp_path, target)
    except Exception:
        tmp_path.unlink(missing_ok=True)
        raise
    try:
        target.chmod(0o600)
    except OSError:  # pragma: no cover
        pass
    return target