filetx 0.1.0__tar.gz

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.
@@ -0,0 +1,46 @@
1
+ # Environments & secrets
2
+ .env
3
+ .env.local
4
+ *.pem
5
+ secrets/
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ .venv/
11
+ venv/
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+ .mypy_cache/
15
+ .coverage
16
+ htmlcov/
17
+ dist/
18
+ build/
19
+ *.egg-info/
20
+
21
+ # Notebooks
22
+ .ipynb_checkpoints/
23
+
24
+ # Data — commit only small samples, never full datasets
25
+ data/raw/
26
+ data/interim/
27
+ data/processed/
28
+ *.parquet
29
+ *.duckdb
30
+ *.db
31
+ *.sqlite3
32
+ !data/sample/**
33
+
34
+ # Models & artifacts
35
+ models/
36
+ *.pt
37
+ *.pth
38
+ *.onnx
39
+ *.pkl
40
+ mlruns/
41
+
42
+ # OS / editor
43
+ .DS_Store
44
+ Thumbs.db
45
+ .vscode/
46
+ .idea/
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ The public API is everything exported from `filetx.__all__`. Anything reachable
9
+ only through a private name or a submodule attribute is not covered by the
10
+ compatibility promise below.
11
+
12
+ ## [Unreleased]
13
+
14
+ ## [0.1.0] - 2026-08-29
15
+
16
+ Initial release.
17
+
18
+ ### Added
19
+
20
+ - `Transaction` — record a batch of filesystem operations and apply them
21
+ all-or-nothing. Usable as a context manager: commits on clean exit, discards
22
+ the plan if the block raises.
23
+ - Operations: `Move`, `Copy`, `Delete`, `Mkdir`. Custom operations can be added
24
+ via `Transaction.add` by subclassing `Operation`.
25
+ - Reversible deletes and overwrites via same-filesystem staging: the target is
26
+ renamed to a hidden sibling rather than removed, so making a delete undoable
27
+ is one atomic rename regardless of size.
28
+ - Write-ahead journal (`Journal`, `read_journal`, JSON Lines). Each undo record
29
+ is written and `fsync`-ed before the change it describes is attempted, so a
30
+ process killed mid-commit leaves a recoverable journal.
31
+ - `recover()` and the `filetx undo` / `filetx inspect` CLI for finishing an
32
+ interrupted transaction after the fact.
33
+ - Distinct `TransactionError` (rolled back cleanly) and `RollbackError`
34
+ (rollback failed, tree indeterminate) so callers can tell the difference.
35
+ - Cross-filesystem moves degrade to copy-then-stage on `EXDEV`; every other
36
+ `OSError` propagates.
37
+ - Full type hints and a `py.typed` marker.
38
+
39
+ ### Notes
40
+
41
+ - Zero runtime dependencies. Supports Python 3.10–3.13.
42
+ - No concurrency control: transactions touching overlapping paths must be
43
+ serialised by the caller.
44
+ - Recovery reports, and never deletes, staged data whose original path has been
45
+ reoccupied.
46
+
47
+ [Unreleased]: https://github.com/Prithv122/filetx/compare/v0.1.0...HEAD
48
+ [0.1.0]: https://github.com/Prithv122/filetx/releases/tag/v0.1.0
filetx-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Prithvi M
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
filetx-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,234 @@
1
+ Metadata-Version: 2.5
2
+ Name: filetx
3
+ Version: 0.1.0
4
+ Summary: Transactional filesystem operations - batch moves, copies and deletes that either all commit or all roll back.
5
+ Project-URL: Homepage, https://github.com/Prithv122/filetx
6
+ Project-URL: Source, https://github.com/Prithv122/filetx
7
+ Project-URL: Issues, https://github.com/Prithv122/filetx/issues
8
+ Project-URL: Changelog, https://github.com/Prithv122/filetx/blob/main/CHANGELOG.md
9
+ Author-email: Prithvi M <feverishpetroleum16@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: atomic,files,filesystem,rollback,transaction,undo
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: System :: Filesystems
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+
26
+ # filetx
27
+
28
+ > Transactional filesystem operations — batch moves, copies and deletes that either all commit or all roll back.
29
+
30
+ [![CI](https://github.com/Prithv122/filetx/actions/workflows/ci.yml/badge.svg)](https://github.com/Prithv122/filetx/actions/workflows/ci.yml)
31
+ [![PyPI](https://img.shields.io/pypi/v/filetx.svg)](https://pypi.org/project/filetx/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/filetx.svg)](https://pypi.org/project/filetx/)
33
+
34
+ **Stack:** Python 3.10+, standard library only, zero runtime dependencies.
35
+
36
+ ```bash
37
+ pip install filetx
38
+ ```
39
+
40
+ ```python
41
+ from filetx import Transaction
42
+
43
+ with Transaction(journal="release.jsonl") as tx:
44
+ tx.mkdir("dist", exist_ok=True)
45
+ tx.move("build/app.bin", "dist/app.bin")
46
+ tx.copy("build/manifest.json", "dist/manifest.json")
47
+ tx.delete("build", missing_ok=True)
48
+ ```
49
+
50
+ Nothing happens until the block exits. If any step fails, every completed step
51
+ is undone. If the process is killed part-way through, `filetx undo release.jsonl`
52
+ finishes the rollback from the journal it left behind.
53
+
54
+ ---
55
+
56
+ ## 1. The problem
57
+
58
+ Multi-step filesystem work has no rollback. A release script that creates a
59
+ directory, moves three artefacts into it and deletes the build tree has four
60
+ chances to fail, and if it fails on the third the disk is left in a state that
61
+ matches neither the before nor the after. The usual responses are to copy
62
+ everything to a backup directory first — which costs time proportional to the
63
+ data, so nobody does it for large trees — or to write bespoke cleanup code per
64
+ script, which is only exercised on the day it is needed and is therefore
65
+ usually wrong.
66
+
67
+ Python has `shutil` for individual operations and `atomicwrites` for making a
68
+ single file write atomic. Neither gives you a *batch* that is all-or-nothing.
69
+ `filetx` is that missing piece: record the operations, commit them, and get the
70
+ tree back unchanged if anything goes wrong — including if the process dies.
71
+
72
+ This started as the transaction log inside a file-organiser tool I built, where
73
+ a half-finished reorganisation of someone's photo library is a genuinely bad
74
+ outcome. The engine turned out to be the interesting part, so it was extracted,
75
+ generalised and published.
76
+
77
+ ## 2. What it operates on
78
+
79
+ There is no dataset here — this is a library, and its input is whatever tree the
80
+ caller points it at.
81
+
82
+ | | |
83
+ |---|---|
84
+ | Input | Arbitrary files, directories and symlinks on a local filesystem |
85
+ | Scale tested | Trees up to 5,000 files; single renames are size-independent |
86
+ | Benchmark data | Synthetic, generated by `scripts/benchmark.py` (seed 42), not committed |
87
+ | Package licence | MIT |
88
+ | Dependencies | None at runtime. `pytest`, `pytest-cov` and `ruff` for development |
89
+
90
+ **Non-goals.** Not a distributed transaction manager, not crash-safe against
91
+ media failure, and not safe against another process mutating the same paths
92
+ concurrently — see [Limitations](#limitations).
93
+
94
+ ## 3. Architecture
95
+
96
+ ```mermaid
97
+ flowchart TD
98
+ A["tx.move / copy / delete / mkdir<br/>(recorded, not performed)"] --> B["commit()"]
99
+ B --> C["plan_undo — validate,<br/>compute undo record"]
100
+ C -->|invalid| R
101
+ C --> D["journal — write undo record, fsync"]
102
+ D --> E["apply — change the filesystem"]
103
+ E -->|OSError| R
104
+ E --> F{"more operations?"}
105
+ F -->|yes| C
106
+ F -->|no| G["journal — commit"]
107
+ G --> H["purge staged material"]
108
+ R["revert applied operations,<br/>in reverse order"] --> S{"all reverted?"}
109
+ S -->|yes| T["journal — rollback<br/>raise TransactionError"]
110
+ S -->|no| U["journal left unsettled<br/>raise RollbackError"]
111
+ U -.->|"later: filetx undo"| R
112
+ ```
113
+
114
+ The undo record is written to the journal **before** the change it describes is
115
+ attempted. That is the whole design: a record written afterwards is useless to a
116
+ process that died in between.
117
+
118
+ Writing it first requires knowing, in advance, where a file is about to be moved
119
+ to — so staged names are derived deterministically from `(path, transaction id,
120
+ plan index)` rather than allocated at random.
121
+
122
+ The remaining ambiguity is that the journal can describe a change that never
123
+ actually happened. Every `revert` therefore inspects the real filesystem before
124
+ acting instead of trusting the record, which makes reverting idempotent: running
125
+ it twice, or against an operation that never applied, does nothing.
126
+
127
+ ## 4. Key decisions & tradeoffs
128
+
129
+ | Decision | Chose | Over | Why |
130
+ |---|---|---|---|
131
+ | Making deletes reversible | Rename the target to a hidden sibling in its own parent directory | Copy it to a staging/backup directory | A sibling is guaranteed to be on the same filesystem, so it is one atomic rename regardless of size — **1,280× faster** on a 5,000-file tree (§5). It also nests correctly: deleting `a/b.txt` then `a` stages the file inside `a`, then renames `a` wholesale, and reverse-order rollback restores `a` before looking for `b.txt` inside it. |
132
+ | When to compute the undo record | Before applying (write-ahead) | After applying, returning what was done | The "after" version cannot recover a process killed between the change and the log write — precisely the window that matters. Cost: staged names must be deterministic, and the journal may record changes that never happened, which is why every revert is state-checking. |
133
+ | Operations execute | Deferred — recorded, applied on `commit()` | Eagerly, as each method is called | Makes the plan inspectable and gives a dry run (`tx.describe()`) for free, and an exception in the caller's own code inside the `with` block costs nothing to undo. |
134
+ | Failure signalling | `TransactionError` and `RollbackError` as distinct types | One exception for "the commit failed" | "Your change didn't happen and the tree is fine" and "your change didn't happen and the tree is in an unknown state" demand completely different responses. Collapsing them would be the most dangerous thing this library could do. |
135
+ | Recovery vs. leftover staged data | Report it, never delete it | Clean up automatically | If a revert declined to restore something because the original path is occupied again, the staged copy may be the only copy left. Unattended deletion there is the one unrecoverable mistake available; `filetx undo` prints the paths and stops. |
136
+ | Cross-filesystem moves | Detect `EXDEV`, degrade to copy-then-stage | Refuse, or always copy | Keeps the fast path fast and the slow path correct. Documented as O(size) rather than O(1). Any other `OSError` propagates — a permission error must not be silently treated as a volume boundary. |
137
+ | Journal format | JSON Lines | SQLite, or a binary log | An operator looking at a half-finished batch can read it with `cat`. Appending one short line is atomic enough at this size, and a torn final line is detected and tolerated. |
138
+
139
+ ## 5. Results
140
+
141
+ Measured on Windows 11 (NTFS), Python 3.13.9, 12-core CPU / 64 GB RAM, median of
142
+ 3 runs. Reproduce with `uv run python scripts/benchmark.py --files 5000 --size 1024`.
143
+
144
+ | Metric | Value | Notes |
145
+ |---|---|---|
146
+ | Make a 5,000-file delete reversible, then undo it | **7.6 ms** | Two renames |
147
+ | Same outcome via copy-to-backup then restore | **9,692 ms** | `shutil.copytree` + `rmtree` + `copytree` |
148
+ | Speedup on the reversibility path | **~1,280×** | Widens with tree size; staging is size-independent |
149
+ | Commit the same delete (data actually removed) | 1,407 ms | Staging is O(1), *deleting* is not — stated so the number above is not mistaken for magic |
150
+ | Tests | **73 passing, 100% line coverage** | Includes two tests that `os._exit()` a real subprocess mid-commit, on either side of the rename, then recover from the journal |
151
+ | Python versions | 3.10, 3.11, 3.12, 3.13 | All four run in CI, not just claimed in metadata |
152
+ | Platforms | Linux, Windows, macOS | Windows and macOS on 3.13; this library is mostly `os.rename`, which is where platforms disagree |
153
+ | Runtime dependencies | 0 | |
154
+
155
+ The benchmark tree is synthetic and generated by the script above. The
156
+ comparison is against `shutil`, i.e. what you would write by hand, not against
157
+ another library.
158
+
159
+ ## 6. How to run
160
+
161
+ ```bash
162
+ git clone https://github.com/Prithv122/filetx.git
163
+ cd filetx
164
+ uv sync
165
+ uv run pytest --cov=src --cov-report=term-missing
166
+ ```
167
+
168
+ No environment variables, services or datasets are needed. Other useful commands:
169
+
170
+ ```bash
171
+ uv run python scripts/benchmark.py # reproduce the numbers in section 5
172
+ uv run ruff check . && uv run ruff format --check .
173
+ uv build && uv run --with twine twine check --strict dist/*
174
+ ```
175
+
176
+ ### Recovering an interrupted run
177
+
178
+ ```bash
179
+ filetx inspect release.jsonl # what was this transaction doing, and how far did it get?
180
+ filetx undo release.jsonl # put it back
181
+ ```
182
+
183
+ `inspect` marks each operation `done`, `PARTIAL` or `-`. `undo` rolls an
184
+ unsettled transaction back, cleans up after a committed one, and reports — but
185
+ never deletes — staged data whose original path has since been reoccupied.
186
+
187
+ ### Limitations
188
+
189
+ - **No locking.** Two transactions touching the same paths concurrently will
190
+ interfere. Serialise them yourself.
191
+ - **`fsync` durability only.** The journal is `fsync`-ed before each change, so
192
+ a process kill or power loss is recoverable, but a lying disk cache or media
193
+ failure is not. Pass `fsync=False` to trade that away for speed.
194
+ - **Staged material is visible.** During a transaction, `.filetx-*` entries
195
+ exist alongside your files. A crash leaves them until `filetx undo` runs;
196
+ `filetx.STAGE_PREFIX` is exported so scanners can skip them.
197
+ - **Cross-filesystem moves are O(size).** They fall back to copying.
198
+
199
+ ## 7. What I'd change at 100× scale
200
+
201
+ The design assumes a batch you can hold in memory and a journal you read whole.
202
+ At 100× — hundreds of thousands of operations per transaction — three things
203
+ break, in this order:
204
+
205
+ 1. **The plan is written to the journal as one record.** A single JSON line
206
+ holding 500k operations has to be serialised and `fsync`-ed before the first
207
+ change happens, and re-parsed in full during recovery. I would stream the
208
+ plan as one record per operation and make `read_journal` incremental.
209
+ 2. **`fsync` per operation dominates.** At ~1 ms per flush, 500k operations is
210
+ over eight minutes of waiting on the disk. I would batch the flush — group
211
+ commits of N records — accepting a bounded window where the journal lags
212
+ reality, and handle it by making recovery re-verify the tail against the
213
+ filesystem, which the state-checking reverts already support.
214
+ 3. **Rollback is serial.** Undoing 500k renames one at a time wastes an SSD's
215
+ parallelism. Reverts of operations on disjoint paths could run in a thread
216
+ pool; the ordering constraint is only between operations whose paths nest,
217
+ which is a partial order the planner could compute up front.
218
+
219
+ What I would *not* change is the staging strategy — it is the part that gets
220
+ better with scale, not worse.
221
+
222
+ ---
223
+
224
+ ## References
225
+
226
+ The write-ahead ordering and the do/undo/purge split follow standard database
227
+ recovery practice; ARIES (Mohan et al., 1992) is the canonical description, and
228
+ the naming of the three phases here is deliberately borrowed from it. No
229
+ implementation was consulted — the filesystem constraints are different enough
230
+ that the resemblance is conceptual.
231
+
232
+ ## Licence
233
+
234
+ MIT — see [LICENSE](LICENSE).
filetx-0.1.0/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # filetx
2
+
3
+ > Transactional filesystem operations — batch moves, copies and deletes that either all commit or all roll back.
4
+
5
+ [![CI](https://github.com/Prithv122/filetx/actions/workflows/ci.yml/badge.svg)](https://github.com/Prithv122/filetx/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/filetx.svg)](https://pypi.org/project/filetx/)
7
+ [![Python](https://img.shields.io/pypi/pyversions/filetx.svg)](https://pypi.org/project/filetx/)
8
+
9
+ **Stack:** Python 3.10+, standard library only, zero runtime dependencies.
10
+
11
+ ```bash
12
+ pip install filetx
13
+ ```
14
+
15
+ ```python
16
+ from filetx import Transaction
17
+
18
+ with Transaction(journal="release.jsonl") as tx:
19
+ tx.mkdir("dist", exist_ok=True)
20
+ tx.move("build/app.bin", "dist/app.bin")
21
+ tx.copy("build/manifest.json", "dist/manifest.json")
22
+ tx.delete("build", missing_ok=True)
23
+ ```
24
+
25
+ Nothing happens until the block exits. If any step fails, every completed step
26
+ is undone. If the process is killed part-way through, `filetx undo release.jsonl`
27
+ finishes the rollback from the journal it left behind.
28
+
29
+ ---
30
+
31
+ ## 1. The problem
32
+
33
+ Multi-step filesystem work has no rollback. A release script that creates a
34
+ directory, moves three artefacts into it and deletes the build tree has four
35
+ chances to fail, and if it fails on the third the disk is left in a state that
36
+ matches neither the before nor the after. The usual responses are to copy
37
+ everything to a backup directory first — which costs time proportional to the
38
+ data, so nobody does it for large trees — or to write bespoke cleanup code per
39
+ script, which is only exercised on the day it is needed and is therefore
40
+ usually wrong.
41
+
42
+ Python has `shutil` for individual operations and `atomicwrites` for making a
43
+ single file write atomic. Neither gives you a *batch* that is all-or-nothing.
44
+ `filetx` is that missing piece: record the operations, commit them, and get the
45
+ tree back unchanged if anything goes wrong — including if the process dies.
46
+
47
+ This started as the transaction log inside a file-organiser tool I built, where
48
+ a half-finished reorganisation of someone's photo library is a genuinely bad
49
+ outcome. The engine turned out to be the interesting part, so it was extracted,
50
+ generalised and published.
51
+
52
+ ## 2. What it operates on
53
+
54
+ There is no dataset here — this is a library, and its input is whatever tree the
55
+ caller points it at.
56
+
57
+ | | |
58
+ |---|---|
59
+ | Input | Arbitrary files, directories and symlinks on a local filesystem |
60
+ | Scale tested | Trees up to 5,000 files; single renames are size-independent |
61
+ | Benchmark data | Synthetic, generated by `scripts/benchmark.py` (seed 42), not committed |
62
+ | Package licence | MIT |
63
+ | Dependencies | None at runtime. `pytest`, `pytest-cov` and `ruff` for development |
64
+
65
+ **Non-goals.** Not a distributed transaction manager, not crash-safe against
66
+ media failure, and not safe against another process mutating the same paths
67
+ concurrently — see [Limitations](#limitations).
68
+
69
+ ## 3. Architecture
70
+
71
+ ```mermaid
72
+ flowchart TD
73
+ A["tx.move / copy / delete / mkdir<br/>(recorded, not performed)"] --> B["commit()"]
74
+ B --> C["plan_undo — validate,<br/>compute undo record"]
75
+ C -->|invalid| R
76
+ C --> D["journal — write undo record, fsync"]
77
+ D --> E["apply — change the filesystem"]
78
+ E -->|OSError| R
79
+ E --> F{"more operations?"}
80
+ F -->|yes| C
81
+ F -->|no| G["journal — commit"]
82
+ G --> H["purge staged material"]
83
+ R["revert applied operations,<br/>in reverse order"] --> S{"all reverted?"}
84
+ S -->|yes| T["journal — rollback<br/>raise TransactionError"]
85
+ S -->|no| U["journal left unsettled<br/>raise RollbackError"]
86
+ U -.->|"later: filetx undo"| R
87
+ ```
88
+
89
+ The undo record is written to the journal **before** the change it describes is
90
+ attempted. That is the whole design: a record written afterwards is useless to a
91
+ process that died in between.
92
+
93
+ Writing it first requires knowing, in advance, where a file is about to be moved
94
+ to — so staged names are derived deterministically from `(path, transaction id,
95
+ plan index)` rather than allocated at random.
96
+
97
+ The remaining ambiguity is that the journal can describe a change that never
98
+ actually happened. Every `revert` therefore inspects the real filesystem before
99
+ acting instead of trusting the record, which makes reverting idempotent: running
100
+ it twice, or against an operation that never applied, does nothing.
101
+
102
+ ## 4. Key decisions & tradeoffs
103
+
104
+ | Decision | Chose | Over | Why |
105
+ |---|---|---|---|
106
+ | Making deletes reversible | Rename the target to a hidden sibling in its own parent directory | Copy it to a staging/backup directory | A sibling is guaranteed to be on the same filesystem, so it is one atomic rename regardless of size — **1,280× faster** on a 5,000-file tree (§5). It also nests correctly: deleting `a/b.txt` then `a` stages the file inside `a`, then renames `a` wholesale, and reverse-order rollback restores `a` before looking for `b.txt` inside it. |
107
+ | When to compute the undo record | Before applying (write-ahead) | After applying, returning what was done | The "after" version cannot recover a process killed between the change and the log write — precisely the window that matters. Cost: staged names must be deterministic, and the journal may record changes that never happened, which is why every revert is state-checking. |
108
+ | Operations execute | Deferred — recorded, applied on `commit()` | Eagerly, as each method is called | Makes the plan inspectable and gives a dry run (`tx.describe()`) for free, and an exception in the caller's own code inside the `with` block costs nothing to undo. |
109
+ | Failure signalling | `TransactionError` and `RollbackError` as distinct types | One exception for "the commit failed" | "Your change didn't happen and the tree is fine" and "your change didn't happen and the tree is in an unknown state" demand completely different responses. Collapsing them would be the most dangerous thing this library could do. |
110
+ | Recovery vs. leftover staged data | Report it, never delete it | Clean up automatically | If a revert declined to restore something because the original path is occupied again, the staged copy may be the only copy left. Unattended deletion there is the one unrecoverable mistake available; `filetx undo` prints the paths and stops. |
111
+ | Cross-filesystem moves | Detect `EXDEV`, degrade to copy-then-stage | Refuse, or always copy | Keeps the fast path fast and the slow path correct. Documented as O(size) rather than O(1). Any other `OSError` propagates — a permission error must not be silently treated as a volume boundary. |
112
+ | Journal format | JSON Lines | SQLite, or a binary log | An operator looking at a half-finished batch can read it with `cat`. Appending one short line is atomic enough at this size, and a torn final line is detected and tolerated. |
113
+
114
+ ## 5. Results
115
+
116
+ Measured on Windows 11 (NTFS), Python 3.13.9, 12-core CPU / 64 GB RAM, median of
117
+ 3 runs. Reproduce with `uv run python scripts/benchmark.py --files 5000 --size 1024`.
118
+
119
+ | Metric | Value | Notes |
120
+ |---|---|---|
121
+ | Make a 5,000-file delete reversible, then undo it | **7.6 ms** | Two renames |
122
+ | Same outcome via copy-to-backup then restore | **9,692 ms** | `shutil.copytree` + `rmtree` + `copytree` |
123
+ | Speedup on the reversibility path | **~1,280×** | Widens with tree size; staging is size-independent |
124
+ | Commit the same delete (data actually removed) | 1,407 ms | Staging is O(1), *deleting* is not — stated so the number above is not mistaken for magic |
125
+ | Tests | **73 passing, 100% line coverage** | Includes two tests that `os._exit()` a real subprocess mid-commit, on either side of the rename, then recover from the journal |
126
+ | Python versions | 3.10, 3.11, 3.12, 3.13 | All four run in CI, not just claimed in metadata |
127
+ | Platforms | Linux, Windows, macOS | Windows and macOS on 3.13; this library is mostly `os.rename`, which is where platforms disagree |
128
+ | Runtime dependencies | 0 | |
129
+
130
+ The benchmark tree is synthetic and generated by the script above. The
131
+ comparison is against `shutil`, i.e. what you would write by hand, not against
132
+ another library.
133
+
134
+ ## 6. How to run
135
+
136
+ ```bash
137
+ git clone https://github.com/Prithv122/filetx.git
138
+ cd filetx
139
+ uv sync
140
+ uv run pytest --cov=src --cov-report=term-missing
141
+ ```
142
+
143
+ No environment variables, services or datasets are needed. Other useful commands:
144
+
145
+ ```bash
146
+ uv run python scripts/benchmark.py # reproduce the numbers in section 5
147
+ uv run ruff check . && uv run ruff format --check .
148
+ uv build && uv run --with twine twine check --strict dist/*
149
+ ```
150
+
151
+ ### Recovering an interrupted run
152
+
153
+ ```bash
154
+ filetx inspect release.jsonl # what was this transaction doing, and how far did it get?
155
+ filetx undo release.jsonl # put it back
156
+ ```
157
+
158
+ `inspect` marks each operation `done`, `PARTIAL` or `-`. `undo` rolls an
159
+ unsettled transaction back, cleans up after a committed one, and reports — but
160
+ never deletes — staged data whose original path has since been reoccupied.
161
+
162
+ ### Limitations
163
+
164
+ - **No locking.** Two transactions touching the same paths concurrently will
165
+ interfere. Serialise them yourself.
166
+ - **`fsync` durability only.** The journal is `fsync`-ed before each change, so
167
+ a process kill or power loss is recoverable, but a lying disk cache or media
168
+ failure is not. Pass `fsync=False` to trade that away for speed.
169
+ - **Staged material is visible.** During a transaction, `.filetx-*` entries
170
+ exist alongside your files. A crash leaves them until `filetx undo` runs;
171
+ `filetx.STAGE_PREFIX` is exported so scanners can skip them.
172
+ - **Cross-filesystem moves are O(size).** They fall back to copying.
173
+
174
+ ## 7. What I'd change at 100× scale
175
+
176
+ The design assumes a batch you can hold in memory and a journal you read whole.
177
+ At 100× — hundreds of thousands of operations per transaction — three things
178
+ break, in this order:
179
+
180
+ 1. **The plan is written to the journal as one record.** A single JSON line
181
+ holding 500k operations has to be serialised and `fsync`-ed before the first
182
+ change happens, and re-parsed in full during recovery. I would stream the
183
+ plan as one record per operation and make `read_journal` incremental.
184
+ 2. **`fsync` per operation dominates.** At ~1 ms per flush, 500k operations is
185
+ over eight minutes of waiting on the disk. I would batch the flush — group
186
+ commits of N records — accepting a bounded window where the journal lags
187
+ reality, and handle it by making recovery re-verify the tail against the
188
+ filesystem, which the state-checking reverts already support.
189
+ 3. **Rollback is serial.** Undoing 500k renames one at a time wastes an SSD's
190
+ parallelism. Reverts of operations on disjoint paths could run in a thread
191
+ pool; the ordering constraint is only between operations whose paths nest,
192
+ which is a partial order the planner could compute up front.
193
+
194
+ What I would *not* change is the staging strategy — it is the part that gets
195
+ better with scale, not worse.
196
+
197
+ ---
198
+
199
+ ## References
200
+
201
+ The write-ahead ordering and the do/undo/purge split follow standard database
202
+ recovery practice; ARIES (Mohan et al., 1992) is the canonical description, and
203
+ the naming of the three phases here is deliberately borrowed from it. No
204
+ implementation was consulted — the filesystem constraints are different enough
205
+ that the resemblance is conceptual.
206
+
207
+ ## Licence
208
+
209
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "filetx"
3
+ dynamic = ["version"] # single-sourced from src/filetx/__init__.py
4
+ description = "Transactional filesystem operations - batch moves, copies and deletes that either all commit or all roll back."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT" # PEP 639 SPDX expression
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Prithvi M", email = "feverishpetroleum16@gmail.com" }]
10
+ keywords = ["filesystem", "transaction", "rollback", "atomic", "undo", "files"]
11
+
12
+ # Zero runtime dependencies. A utility library that pulls in a dependency tree
13
+ # is a library people vendor around instead of installing.
14
+ dependencies = []
15
+
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: System :: Filesystems",
26
+ "Typing :: Typed",
27
+ ]
28
+ # No "License ::" classifier - PEP 639 deprecates it when `license` is an SPDX
29
+ # expression, and PyPI rejects uploads that specify both.
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/Prithv122/filetx"
33
+ Source = "https://github.com/Prithv122/filetx"
34
+ Issues = "https://github.com/Prithv122/filetx/issues"
35
+ Changelog = "https://github.com/Prithv122/filetx/blob/main/CHANGELOG.md"
36
+
37
+ [project.scripts]
38
+ filetx = "filetx.cli:main"
39
+
40
+ [build-system]
41
+ requires = ["hatchling>=1.27"] # >=1.27 for PEP 639 license support
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.version]
45
+ path = "src/filetx/__init__.py"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/filetx"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["src", "tests", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
52
+
53
+ [dependency-groups]
54
+ dev = [
55
+ "pytest>=8.3",
56
+ "pytest-cov>=6.0",
57
+ "ruff>=0.9",
58
+ ]
59
+
60
+ [tool.pytest.ini_options]
61
+ pythonpath = ["src"]
62
+ testpaths = ["tests"]
63
+ addopts = "-q"
64
+
65
+ [tool.ruff]
66
+ line-length = 100
67
+ src = ["src", "tests"]
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]