filetx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
filetx/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """Transactional filesystem operations.
2
+
3
+ Batch moves, copies and deletes that either all commit or all roll back::
4
+
5
+ from filetx import Transaction
6
+
7
+ with Transaction(journal="release.jsonl") as tx:
8
+ tx.mkdir("dist", exist_ok=True)
9
+ tx.move("build/app.bin", "dist/app.bin")
10
+ tx.delete("build", missing_ok=True)
11
+
12
+ If any step fails, every completed step is undone and the tree is left as it
13
+ was found. If the process is killed part-way through, ``filetx undo
14
+ release.jsonl`` finishes the rollback from the journal.
15
+
16
+ See :class:`Transaction` for the API, :mod:`filetx.ops` for how reversibility
17
+ is implemented, and :mod:`filetx.journal` for the on-disk format.
18
+ """
19
+
20
+ from .errors import (
21
+ FiletxError,
22
+ JournalError,
23
+ OperationError,
24
+ RollbackError,
25
+ TransactionError,
26
+ )
27
+ from .journal import JOURNAL_VERSION, Journal, JournalFile, read_journal
28
+ from .ops import STAGE_PREFIX, Copy, Delete, Mkdir, Move, Operation
29
+ from .transaction import Recovery, Transaction, recover
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ __all__ = [
34
+ "JOURNAL_VERSION",
35
+ "STAGE_PREFIX",
36
+ "Copy",
37
+ "Delete",
38
+ "FiletxError",
39
+ "Journal",
40
+ "JournalError",
41
+ "JournalFile",
42
+ "Mkdir",
43
+ "Move",
44
+ "Operation",
45
+ "OperationError",
46
+ "Recovery",
47
+ "RollbackError",
48
+ "Transaction",
49
+ "TransactionError",
50
+ "__version__",
51
+ "read_journal",
52
+ "recover",
53
+ ]
filetx/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """Command-line interface.
2
+
3
+ The library is the product; the CLI exists for the one job you cannot do from
4
+ inside the crashed process -- finishing an interrupted transaction after the
5
+ fact::
6
+
7
+ filetx inspect release.jsonl # what was this transaction doing?
8
+ filetx undo release.jsonl # put it back
9
+
10
+ Exit codes: ``0`` success, ``1`` recovery failed or the journal is unusable,
11
+ ``2`` bad arguments (argparse's own convention).
12
+ """
13
+
14
+ import argparse
15
+ import sys
16
+ from collections.abc import Sequence
17
+
18
+ from . import __version__
19
+ from .errors import FiletxError, RollbackError
20
+ from .journal import read_journal
21
+ from .transaction import recover
22
+
23
+ __all__ = ["main"]
24
+
25
+
26
+ def _build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="filetx",
29
+ description="Inspect and recover filetx transaction journals.",
30
+ )
31
+ parser.add_argument("--version", action="version", version=f"filetx {__version__}")
32
+ sub = parser.add_subparsers(dest="command", required=True)
33
+
34
+ undo = sub.add_parser("undo", help="finish an interrupted transaction from its journal")
35
+ undo.add_argument("journal", help="path to the journal file")
36
+ undo.add_argument(
37
+ "--no-fsync",
38
+ action="store_true",
39
+ help="skip fsync when settling the journal (faster, less durable)",
40
+ )
41
+
42
+ inspect = sub.add_parser("inspect", help="show a journal's plan and status")
43
+ inspect.add_argument("journal", help="path to the journal file")
44
+
45
+ return parser
46
+
47
+
48
+ def _cmd_inspect(args: argparse.Namespace) -> int:
49
+ journal = read_journal(args.journal)
50
+ status = journal.terminal or "INTERRUPTED"
51
+ print(f"transaction {journal.txid} journal v{journal.version} status: {status}")
52
+ print(f"{len(journal.plan)} operation(s) planned:")
53
+ for index, op in enumerate(journal.plan):
54
+ if index in journal.completed:
55
+ mark = "done"
56
+ elif index in journal.attempted:
57
+ mark = "PARTIAL"
58
+ else:
59
+ mark = "-"
60
+ print(f" [{index:>3}] {mark:<8} {op.describe()}")
61
+ if not journal.settled:
62
+ print("\nThis transaction never settled. Run `filetx undo` to roll it back.")
63
+ return 0
64
+
65
+
66
+ def _cmd_undo(args: argparse.Namespace) -> int:
67
+ result = recover(args.journal, fsync=not args.no_fsync)
68
+
69
+ if result.action == "rolled-back":
70
+ print(f"rolled back transaction {result.txid}: {len(result.reverted)} operation(s) undone")
71
+ elif result.action == "purged":
72
+ print(f"transaction {result.txid} had committed; cleaned up {len(result.purged)} entry(s)")
73
+ else:
74
+ print(f"transaction {result.txid} was already settled; nothing to do")
75
+
76
+ if result.leftover:
77
+ print(
78
+ f"\n{len(result.leftover)} staged entry(s) were kept rather than deleted, because "
79
+ f"their original paths are occupied again and these may be the only copies left:"
80
+ )
81
+ for path in result.leftover:
82
+ print(f" {path}")
83
+ print("Review them, then remove them by hand.")
84
+ return 0
85
+
86
+
87
+ def main(argv: Sequence[str] | None = None) -> int:
88
+ """Entry point. Returns the process exit code."""
89
+ args = _build_parser().parse_args(argv)
90
+ handlers = {"inspect": _cmd_inspect, "undo": _cmd_undo}
91
+ try:
92
+ return handlers[args.command](args)
93
+ except RollbackError as exc:
94
+ print(f"filetx: {exc}", file=sys.stderr)
95
+ for index, failure in exc.failures:
96
+ print(f" operation {index}: {failure}", file=sys.stderr)
97
+ print(
98
+ "The journal was left unsettled. Fix the underlying problem and run "
99
+ "`filetx undo` again.",
100
+ file=sys.stderr,
101
+ )
102
+ return 1
103
+ except FiletxError as exc:
104
+ print(f"filetx: {exc}", file=sys.stderr)
105
+ return 1
106
+
107
+
108
+ if __name__ == "__main__": # pragma: no cover
109
+ raise SystemExit(main())
filetx/errors.py ADDED
@@ -0,0 +1,72 @@
1
+ """Exception hierarchy.
2
+
3
+ The distinction that matters is :class:`TransactionError` versus
4
+ :class:`RollbackError`. The first means "your change did not happen, and the
5
+ tree is as you left it" -- recoverable, and the caller can retry. The second
6
+ means "your change did not happen and undoing it *also* failed" -- the tree is
7
+ in an indeterminate state and a human has to look at it. Collapsing those two
8
+ into one exception type would be the single most dangerous thing this library
9
+ could do, so they are separate and ``RollbackError`` carries the failures.
10
+ """
11
+
12
+ __all__ = [
13
+ "FiletxError",
14
+ "JournalError",
15
+ "OperationError",
16
+ "RollbackError",
17
+ "TransactionError",
18
+ ]
19
+
20
+
21
+ class FiletxError(Exception):
22
+ """Base class for every error raised by filetx."""
23
+
24
+
25
+ class OperationError(FiletxError):
26
+ """A single operation could not be applied.
27
+
28
+ Raised during :meth:`filetx.Transaction.commit` and normally wrapped in a
29
+ :class:`TransactionError` after the rollback has completed.
30
+ """
31
+
32
+
33
+ class TransactionError(FiletxError):
34
+ """A commit failed and was rolled back successfully.
35
+
36
+ The filesystem is back in its pre-commit state. The underlying cause is
37
+ available as ``__cause__`` and as :attr:`cause`.
38
+ """
39
+
40
+ def __init__(self, message: str, *, index: int, cause: BaseException) -> None:
41
+ super().__init__(message)
42
+ self.index = index
43
+ """Position in the plan of the operation that failed."""
44
+ self.cause = cause
45
+ """The exception that caused the failure."""
46
+
47
+
48
+ class RollbackError(FiletxError):
49
+ """A commit failed *and* the rollback failed.
50
+
51
+ The filesystem is in an indeterminate state: some operations were undone
52
+ and some were not. :attr:`failures` lists what could not be undone, and
53
+ :attr:`journal` points at the journal file to hand to ``filetx undo``
54
+ once the underlying problem is fixed.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ message: str,
60
+ *,
61
+ failures: list[tuple[int, BaseException]],
62
+ journal: str | None = None,
63
+ ) -> None:
64
+ super().__init__(message)
65
+ self.failures = failures
66
+ """``(plan index, exception)`` for each operation that could not be undone."""
67
+ self.journal = journal
68
+ """Path to the journal file, if the transaction was journalled."""
69
+
70
+
71
+ class JournalError(FiletxError):
72
+ """A journal file is missing, unreadable, or written by an incompatible version."""
filetx/journal.py ADDED
@@ -0,0 +1,195 @@
1
+ """Write-ahead journal.
2
+
3
+ The journal is what makes a crash survivable. Each record is one JSON object
4
+ on its own line, flushed and ``fsync``-ed *before* the filesystem change it
5
+ describes is attempted. The journal on disk is therefore never behind reality
6
+ -- at worst it records an operation as attempted that did not in fact happen,
7
+ which is the safe direction to be wrong in, because every ``revert`` checks
8
+ the real filesystem before acting.
9
+
10
+ Record sequence for a transaction::
11
+
12
+ {"event": "begin", "version": 1, "tx": "...", "plan": [...]}
13
+ {"event": "apply", "index": 0, "undo": {...}}
14
+ {"event": "done", "index": 0}
15
+ {"event": "apply", "index": 1, "undo": {...}}
16
+ {"event": "fail", "index": 1, "error": "..."}
17
+ {"event": "rollback"}
18
+
19
+ A journal whose last record is ``commit`` or ``rollback`` is *settled*.
20
+ Anything else is an interrupted transaction that :func:`filetx.recover` can
21
+ finish, using the undo records already on disk.
22
+
23
+ JSON Lines rather than a binary format or SQLite: an operator staring at a
24
+ half-finished batch can read it with ``cat``, and appending one short line is
25
+ already atomic enough for records this size.
26
+ """
27
+
28
+ import json
29
+ import os
30
+ import time
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from types import TracebackType
34
+ from typing import Any
35
+
36
+ from .errors import JournalError
37
+ from .ops import Operation, UndoRecord, operation_from_dict
38
+
39
+ __all__ = ["JOURNAL_VERSION", "Journal", "JournalFile", "read_journal"]
40
+
41
+ JOURNAL_VERSION = 1
42
+ """Bumped only on a breaking change to the record format."""
43
+
44
+ SETTLED = ("commit", "rollback")
45
+
46
+
47
+ class Journal:
48
+ """Append-only writer for one transaction's journal.
49
+
50
+ Args:
51
+ path: File to append to. Created if missing.
52
+ fsync: Force each record to stable storage before returning. Correct
53
+ by default, and substantially slower than buffered writes. Turn it
54
+ off only when the journal is a debugging aid rather than a
55
+ recovery mechanism.
56
+ """
57
+
58
+ def __init__(self, path: str | os.PathLike[str], *, fsync: bool = True) -> None:
59
+ self.path = Path(path)
60
+ self.fsync = fsync
61
+ try:
62
+ self._fh = self.path.open("a", encoding="utf-8", newline="\n")
63
+ except OSError as exc:
64
+ raise JournalError(f"cannot open journal {self.path}: {exc}") from exc
65
+
66
+ def __enter__(self) -> "Journal":
67
+ return self
68
+
69
+ def __exit__(
70
+ self,
71
+ exc_type: type[BaseException] | None,
72
+ exc: BaseException | None,
73
+ tb: TracebackType | None,
74
+ ) -> None:
75
+ self.close()
76
+
77
+ def close(self) -> None:
78
+ """Close the underlying file. Safe to call more than once."""
79
+ if not self._fh.closed:
80
+ self._fh.close()
81
+
82
+ def _write(self, record: dict[str, Any]) -> None:
83
+ record["ts"] = time.time()
84
+ self._fh.write(json.dumps(record, sort_keys=True) + "\n")
85
+ self._fh.flush()
86
+ if self.fsync:
87
+ os.fsync(self._fh.fileno())
88
+
89
+ def begin(self, txid: str, plan: list[Operation]) -> None:
90
+ """Record the full plan before any of it is attempted."""
91
+ self._write(
92
+ {
93
+ "event": "begin",
94
+ "version": JOURNAL_VERSION,
95
+ "tx": txid,
96
+ "plan": [op.to_dict() for op in plan],
97
+ }
98
+ )
99
+
100
+ def apply(self, index: int, undo: UndoRecord) -> None:
101
+ """Record how to undo an operation, before it is attempted."""
102
+ self._write({"event": "apply", "index": index, "undo": undo})
103
+
104
+ def done(self, index: int) -> None:
105
+ """Record that an operation completed."""
106
+ self._write({"event": "done", "index": index})
107
+
108
+ def fail(self, index: int, error: str) -> None:
109
+ """Record that an operation failed."""
110
+ self._write({"event": "fail", "index": index, "error": error})
111
+
112
+ def commit(self) -> None:
113
+ """Settle the journal: the transaction is permanent."""
114
+ self._write({"event": "commit"})
115
+
116
+ def rollback(self) -> None:
117
+ """Settle the journal: the transaction was undone."""
118
+ self._write({"event": "rollback"})
119
+
120
+
121
+ @dataclass
122
+ class JournalFile:
123
+ """A parsed journal."""
124
+
125
+ path: Path
126
+ txid: str
127
+ version: int
128
+ plan: list[Operation]
129
+ attempted: dict[int, UndoRecord] = field(default_factory=dict)
130
+ """Undo records for every operation that was started, keyed by plan index.
131
+ An operation is in here whether or not it finished -- that is the point."""
132
+ completed: set[int] = field(default_factory=set)
133
+ """Operations that ran to completion."""
134
+ terminal: str | None = None
135
+ """``"commit"``, ``"rollback"``, or ``None`` if the transaction never settled."""
136
+
137
+ @property
138
+ def settled(self) -> bool:
139
+ """True if the transaction reached a definite end."""
140
+ return self.terminal in SETTLED
141
+
142
+
143
+ def read_journal(path: str | os.PathLike[str]) -> JournalFile:
144
+ """Parse a journal file.
145
+
146
+ Raises:
147
+ JournalError: The file is missing, malformed, has no ``begin`` record,
148
+ or was written by an incompatible journal version.
149
+ """
150
+ path = Path(path)
151
+ try:
152
+ lines = path.read_text(encoding="utf-8").splitlines()
153
+ except OSError as exc:
154
+ raise JournalError(f"cannot read journal {path}: {exc}") from exc
155
+
156
+ records: list[dict[str, Any]] = []
157
+ for lineno, line in enumerate(lines, start=1):
158
+ if not line.strip():
159
+ continue
160
+ try:
161
+ records.append(json.loads(line))
162
+ except json.JSONDecodeError as exc:
163
+ # A torn last line means the process died mid-write. Everything
164
+ # before it is still valid, so keep that and stop.
165
+ if lineno == len(lines):
166
+ break
167
+ raise JournalError(f"{path}:{lineno}: malformed journal record: {exc}") from exc
168
+
169
+ if not records or records[0].get("event") != "begin":
170
+ raise JournalError(f"{path}: no begin record -- not a filetx journal")
171
+
172
+ begin = records[0]
173
+ version = begin.get("version")
174
+ if version != JOURNAL_VERSION:
175
+ raise JournalError(
176
+ f"{path}: journal version {version!r}, this filetx understands {JOURNAL_VERSION}"
177
+ )
178
+
179
+ journal = JournalFile(
180
+ path=path,
181
+ txid=begin.get("tx", ""),
182
+ version=version,
183
+ plan=[operation_from_dict(d) for d in begin.get("plan", [])],
184
+ )
185
+
186
+ for record in records[1:]:
187
+ event = record.get("event")
188
+ if event == "apply":
189
+ journal.attempted[record["index"]] = record.get("undo", {})
190
+ elif event == "done":
191
+ journal.completed.add(record["index"])
192
+ elif event in SETTLED:
193
+ journal.terminal = event
194
+
195
+ return journal