txstore 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.
Files changed (107) hide show
  1. txstore-0.1.0/.gitignore +13 -0
  2. txstore-0.1.0/AGENTS.md +80 -0
  3. txstore-0.1.0/CLAUDE.md +1 -0
  4. txstore-0.1.0/CONFIG.md +90 -0
  5. txstore-0.1.0/EXCEPTIONS.md +46 -0
  6. txstore-0.1.0/LICENSE +21 -0
  7. txstore-0.1.0/PKG-INFO +151 -0
  8. txstore-0.1.0/README.md +121 -0
  9. txstore-0.1.0/ROADMAP.md +66 -0
  10. txstore-0.1.0/SETUP.md +116 -0
  11. txstore-0.1.0/STORAGE.md +434 -0
  12. txstore-0.1.0/STRUCTURE.md +58 -0
  13. txstore-0.1.0/STYLE.md +43 -0
  14. txstore-0.1.0/TESTING.md +92 -0
  15. txstore-0.1.0/VALIDATION.md +151 -0
  16. txstore-0.1.0/docs/development/README.md +31 -0
  17. txstore-0.1.0/docs/development/architecture.md +52 -0
  18. txstore-0.1.0/docs/development/class.md +51 -0
  19. txstore-0.1.0/docs/development/diagrams/architecture-modules.puml +53 -0
  20. txstore-0.1.0/docs/development/diagrams/architecture-modules.svg +2 -0
  21. txstore-0.1.0/docs/development/diagrams/class-storage.puml +71 -0
  22. txstore-0.1.0/docs/development/diagrams/class-storage.svg +2 -0
  23. txstore-0.1.0/docs/development/diagrams/footmark-token-path.puml +10 -0
  24. txstore-0.1.0/docs/development/diagrams/footmark-token-path.svg +2 -0
  25. txstore-0.1.0/docs/development/diagrams/s3-metadata-rewrite.puml +18 -0
  26. txstore-0.1.0/docs/development/diagrams/s3-metadata-rewrite.svg +2 -0
  27. txstore-0.1.0/docs/development/diagrams/s3-writer-lock.puml +27 -0
  28. txstore-0.1.0/docs/development/diagrams/s3-writer-lock.svg +2 -0
  29. txstore-0.1.0/docs/development/diagrams/transactions-commit.puml +25 -0
  30. txstore-0.1.0/docs/development/diagrams/transactions-commit.svg +2 -0
  31. txstore-0.1.0/docs/development/diagrams/transactions-discard.puml +13 -0
  32. txstore-0.1.0/docs/development/diagrams/transactions-discard.svg +2 -0
  33. txstore-0.1.0/docs/development/diagrams/transactions-revert.puml +13 -0
  34. txstore-0.1.0/docs/development/diagrams/transactions-revert.svg +2 -0
  35. txstore-0.1.0/docs/development/footmark.md +56 -0
  36. txstore-0.1.0/docs/development/s3.md +76 -0
  37. txstore-0.1.0/docs/development/transactions.md +97 -0
  38. txstore-0.1.0/docs/guides/README.md +37 -0
  39. txstore-0.1.0/docs/guides/backends.md +91 -0
  40. txstore-0.1.0/docs/guides/history.md +81 -0
  41. txstore-0.1.0/docs/guides/introduction.md +52 -0
  42. txstore-0.1.0/docs/guides/transactions.md +63 -0
  43. txstore-0.1.0/docs/reference/README.md +10 -0
  44. txstore-0.1.0/docs/reference/footmark.proto +15 -0
  45. txstore-0.1.0/pyproject.toml +138 -0
  46. txstore-0.1.0/scripts/check_docs.py +501 -0
  47. txstore-0.1.0/scripts/render_diagrams.py +173 -0
  48. txstore-0.1.0/src/txstore/__init__.py +108 -0
  49. txstore-0.1.0/src/txstore/_capabilities.py +185 -0
  50. txstore-0.1.0/src/txstore/_env.py +70 -0
  51. txstore-0.1.0/src/txstore/_footmark_wire.py +182 -0
  52. txstore-0.1.0/src/txstore/_fs_paths.py +279 -0
  53. txstore-0.1.0/src/txstore/_reclaim.py +217 -0
  54. txstore-0.1.0/src/txstore/_reserved.py +47 -0
  55. txstore-0.1.0/src/txstore/_s3_acl.py +57 -0
  56. txstore-0.1.0/src/txstore/_s3_client.py +462 -0
  57. txstore-0.1.0/src/txstore/_s3_errors.py +119 -0
  58. txstore-0.1.0/src/txstore/_s3_lock.py +366 -0
  59. txstore-0.1.0/src/txstore/_timestamp.py +128 -0
  60. txstore-0.1.0/src/txstore/base.py +208 -0
  61. txstore-0.1.0/src/txstore/factory.py +157 -0
  62. txstore-0.1.0/src/txstore/footmark.py +195 -0
  63. txstore-0.1.0/src/txstore/hexdigest.py +162 -0
  64. txstore-0.1.0/src/txstore/history.py +633 -0
  65. txstore-0.1.0/src/txstore/metrics.py +113 -0
  66. txstore-0.1.0/src/txstore/py.typed +0 -0
  67. txstore-0.1.0/src/txstore/storage_fs.py +686 -0
  68. txstore-0.1.0/src/txstore/storage_http.py +258 -0
  69. txstore-0.1.0/src/txstore/storage_null.py +114 -0
  70. txstore-0.1.0/src/txstore/storage_s3.py +727 -0
  71. txstore-0.1.0/src/txstore/transaction.py +1086 -0
  72. txstore-0.1.0/src/txstore/url.py +426 -0
  73. txstore-0.1.0/tests/conftest.py +93 -0
  74. txstore-0.1.0/tests/helpers/__init__.py +0 -0
  75. txstore-0.1.0/tests/helpers/backends.py +88 -0
  76. txstore-0.1.0/tests/helpers/s3.py +89 -0
  77. txstore-0.1.0/tests/helpers/storage.py +384 -0
  78. txstore-0.1.0/tests/test_base.py +197 -0
  79. txstore-0.1.0/tests/test_capabilities.py +119 -0
  80. txstore-0.1.0/tests/test_check_docs.py +277 -0
  81. txstore-0.1.0/tests/test_env.py +75 -0
  82. txstore-0.1.0/tests/test_factory.py +369 -0
  83. txstore-0.1.0/tests/test_footmark.py +185 -0
  84. txstore-0.1.0/tests/test_footmark_wire.py +86 -0
  85. txstore-0.1.0/tests/test_fs_paths.py +456 -0
  86. txstore-0.1.0/tests/test_fs_transactions.py +617 -0
  87. txstore-0.1.0/tests/test_harness.py +62 -0
  88. txstore-0.1.0/tests/test_hexdigest.py +128 -0
  89. txstore-0.1.0/tests/test_history.py +1363 -0
  90. txstore-0.1.0/tests/test_metrics.py +93 -0
  91. txstore-0.1.0/tests/test_package.py +408 -0
  92. txstore-0.1.0/tests/test_render_diagrams.py +255 -0
  93. txstore-0.1.0/tests/test_reserved.py +264 -0
  94. txstore-0.1.0/tests/test_s3_acl.py +112 -0
  95. txstore-0.1.0/tests/test_s3_client.py +424 -0
  96. txstore-0.1.0/tests/test_s3_errors.py +183 -0
  97. txstore-0.1.0/tests/test_s3_lock.py +657 -0
  98. txstore-0.1.0/tests/test_s3_transactions.py +686 -0
  99. txstore-0.1.0/tests/test_storage_fs.py +918 -0
  100. txstore-0.1.0/tests/test_storage_http.py +580 -0
  101. txstore-0.1.0/tests/test_storage_null.py +135 -0
  102. txstore-0.1.0/tests/test_storage_s3.py +954 -0
  103. txstore-0.1.0/tests/test_timestamp.py +122 -0
  104. txstore-0.1.0/tests/test_transaction.py +145 -0
  105. txstore-0.1.0/tests/test_transactions.py +2133 -0
  106. txstore-0.1.0/tests/test_url.py +584 -0
  107. txstore-0.1.0/uv.lock +1824 -0
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .coverage
8
+ coverage.xml
9
+ htmlcov/
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ .env
@@ -0,0 +1,80 @@
1
+ # AI coding agent guide
2
+
3
+ Root contracts decide behavior; the implementation shows current state. Keep one canonical owner per
4
+ fact; link instead of copying.
5
+
6
+ ## Source priority
7
+
8
+ 1. The root contract that owns the domain below; `STYLE.md` prevails on style.
9
+ 2. Implementation under `src/txstore/` when checking current behavior.
10
+ 3. `docs/guides/` for user how-tos, `docs/development/` for contributor explanations and diagrams,
11
+ `docs/reference/` for reference artifacts, and `ROADMAP.md` for planned work and known
12
+ deviations. All are non-canonical.
13
+
14
+ Resolve drift by updating the owning contract and implementation together, not by adding a second
15
+ definition.
16
+
17
+ ## Canonical sources
18
+
19
+ | File | Owns |
20
+ | -- | -- |
21
+ | `STYLE.md` | Coding conventions, logging rules, performance rules; prevails on style |
22
+ | `STRUCTURE.md` | Layout, module ownership, public façade, internal import directions |
23
+ | `STORAGE.md` | Storage ABI, backends, construction, transactions, footmarks and their wire format, timestamps, history, S3 ACL and metadata rules, digests |
24
+ | `VALIDATION.md` | Input constraints at the URL, path, configuration, and retention boundaries |
25
+ | `EXCEPTIONS.md` | Exception classes, raising conditions, chaining rules |
26
+ | `CONFIG.md` | `make_storage` and retention settings, `TX_STORE_*` variables, defaults, secrets |
27
+ | `TESTING.md` | Required coverage, harness rules, gates |
28
+ | `SETUP.md` | Install, test, quality gates, diagrams, dependency policy, release, upgrade notes |
29
+
30
+ ## Task routing
31
+
32
+ Read only the relevant sections; locate code from [STRUCTURE.md](STRUCTURE.md#layout).
33
+
34
+ | Task | Primary | Also check |
35
+ | -- | -- | -- |
36
+ | Backend / primitive | `STORAGE.md` backends and primitives | `STRUCTURE.md` layout, `VALIDATION.md` URLs, `TESTING.md` |
37
+ | Transaction / history / lock | `STORAGE.md` transactions and history | `EXCEPTIONS.md`, `TESTING.md` |
38
+ | Footmark wire / timestamp | `STORAGE.md` footmarks and timestamps | `docs/reference/footmark.proto`, `TESTING.md` golden vectors |
39
+ | S3 ACL / metadata | `STORAGE.md` S3 sections | `VALIDATION.md` canned ACLs, `CONFIG.md` |
40
+ | URL / path rule | `VALIDATION.md` | `EXCEPTIONS.md`, `TESTING.md` |
41
+ | Exception | `EXCEPTIONS.md` | Owning contract of the condition |
42
+ | Argument / `TX_STORE_*` variable | `CONFIG.md` | `VALIDATION.md` environment values, `src/txstore/_env.py` |
43
+ | Digest | `STORAGE.md` digests | `src/txstore/hexdigest.py` |
44
+ | Import boundary | `STRUCTURE.md` dependencies | `tests/test_package.py`, `STYLE.md` |
45
+ | Dependency change | `SETUP.md` dependency policy | `STRUCTURE.md` dependencies, `tests/test_package.py`, `.github/debian-test.sh` |
46
+ | Bug fix | Owning contract + `EXCEPTIONS.md` | `TESTING.md` |
47
+ | Tests | `TESTING.md` | Contract for the behavior under test |
48
+ | Contract / doc edit | Writing rules below → owning contract | `scripts/check_docs.py`, `SETUP.md` quality gates |
49
+ | Release | `SETUP.md` release | `.github/workflows/release.yml`, `ROADMAP.md` phases |
50
+ | Quality gate / CI change | `SETUP.md` quality gates | `.github/workflows/ci.yml` |
51
+ | Developer explanation / diagram | `docs/development/` | Owning root contract, `SETUP.md` diagrams |
52
+ | User how-to / future behavior | `docs/guides/` / `ROADMAP.md` | Owning root contract before implementing |
53
+
54
+ ## Agent workflow
55
+
56
+ 1. Read only the owning contract sections from the routing table; open the module named in
57
+ [STRUCTURE.md](STRUCTURE.md#layout).
58
+ 2. Implement within the import directions of [STRUCTURE.md](STRUCTURE.md#dependencies). Write errors
59
+ as `condition → ExceptionClass` and link `EXCEPTIONS.md` instead of copying its table. A new
60
+ setting → [CONFIG.md](CONFIG.md#maintenance); a new public name →
61
+ [STRUCTURE.md](STRUCTURE.md#maintenance).
62
+ 3. Add the narrowest test that falsifies the change per [TESTING.md](TESTING.md#maintenance).
63
+ 4. Run the [SETUP.md quality gates](SETUP.md#quality-gates); contract and `docs/` edits also run
64
+ `uv run python scripts/check_docs.py`. CI runs the gates again.
65
+
66
+ ## Contract writing rules
67
+
68
+ - One owner per fact. Link it as `→ FILE.md#anchor`; never copy its table or values.
69
+ - Preamble opens with `Owns: <domain>. Routing → AGENTS.md.`, optionally followed by one short
70
+ orienting paragraph. No cross-document pointer chains.
71
+ - Errors: `condition → ExceptionClass`; the class must be in `EXCEPTIONS.md` or be `ValueError` or
72
+ `NotImplementedError`.
73
+ - Prefer tables; one fact per row. Narrative cells stay under ~300 characters: split rows rather
74
+ than pad. Enumerations (method lists, value lists) are exempt.
75
+ - Tables use `| -- |` separator rows and unpadded cells; do not run a table aligner.
76
+ - Bare imperative rules; no RFC 2119 keywords. Headings are sentence case and unique per file.
77
+ - No TODOs or "current exception" notes in contracts; record them in
78
+ [ROADMAP.md known deviations](ROADMAP.md#known-deviations) and link.
79
+ - Wrap prose at 100 columns; table rows are exempt. Prefer `→` shortcuts to sentences.
80
+ - After any contract or `docs/` edit run `uv run python scripts/check_docs.py`.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,90 @@
1
+ # Configuration contract
2
+
3
+ Owns: `make_storage` and `HistoryRetention.from_env` settings, their `TX_STORE_*` environment
4
+ variables, resolution order, defaults, and secrets. Routing →
5
+ [AGENTS.md](AGENTS.md#canonical-sources).
6
+
7
+ txstore's own configuration is keyword arguments and `TX_STORE_*` variables only; an application
8
+ that exposes its own flags maps them to the keyword arguments below. The HTTP and S3 clients also
9
+ honour their libraries' standard environment → [Secrets](#secrets).
10
+
11
+ ## Resolution
12
+
13
+ | Rule | Behavior |
14
+ | -- | -- |
15
+ | Order | Explicit keyword argument, then the `TX_STORE_*` variable, then the default. A `None` argument counts as not given. |
16
+ | Lazy read | A variable is read only when its argument is `None`, so an invalid value fails only a call that falls back to it. |
17
+ | Readers | Only `make_storage` and `HistoryRetention.from_env` read variables; backend constructors and `HistoryRetention(...)` take explicit values only. |
18
+ | Timing | Variables are read on each call, never at import. |
19
+ | Values | Grammar and failures → [VALIDATION.md](VALIDATION.md#environment-values). |
20
+
21
+ ## Storage construction
22
+
23
+ ```python
24
+ make_storage(url, *, create_path=False, verify_ssl=None, s3_access=None, s3_secret=None,
25
+ s3_default_acl=None, s3_bucket_acl=None)
26
+ ```
27
+
28
+ → [STORAGE.md](STORAGE.md#construction).
29
+
30
+ | Argument | Variable | Default | Notes |
31
+ | -- | -- | -- | -- |
32
+ | `create_path` | none | `False` | Create the S3 bucket or FS root when missing. |
33
+ | `verify_ssl` | `TX_STORE_VERIFY_SSL` | `True` | TLS verification for HTTP and S3; resolving to `False` logs a warning. |
34
+ | `s3_access` | `TX_STORE_S3_ACCESS` | none | Secret; required for `s3://`. |
35
+ | `s3_secret` | `TX_STORE_S3_SECRET` | none | Secret; required for `s3://`. |
36
+ | `s3_default_acl` | `TX_STORE_S3_DEFAULT_ACL` | `none` (`NO_ACL`) | Object ACL for writes that pass none → [STORAGE.md](STORAGE.md#s3-acl-rules); values → [VALIDATION.md](VALIDATION.md#s3-canned-acls). |
37
+ | `s3_bucket_acl` | `TX_STORE_S3_BUCKET_ACL` | `None` | Bucket ACL at creation → [STORAGE.md](STORAGE.md#construction). |
38
+
39
+ ## History retention
40
+
41
+ `HistoryRetention.from_env(*, keep=None, max_age_days=None)` → [STORAGE.md](STORAGE.md#retention);
42
+ values → [VALIDATION.md](VALIDATION.md#history-retention).
43
+
44
+ | Argument | Variable | Default | Notes |
45
+ | -- | -- | -- | -- |
46
+ | `keep` | `TX_STORE_HISTORY_KEEP` | `20` (`HISTORY_KEEP_DEFAULT`) | Floor of protected ids → [STORAGE.md](STORAGE.md#retention). |
47
+ | `max_age_days` | `TX_STORE_HISTORY_MAX_AGE_DAYS` | `90` (`HISTORY_MAX_AGE_DAYS_DEFAULT`) | Expiry age → [STORAGE.md](STORAGE.md#retention). |
48
+
49
+ ## Environment variables
50
+
51
+ | Variable | Read by | Type |
52
+ | -- | -- | -- |
53
+ | `TX_STORE_VERIFY_SSL` | `make_storage` | Boolean |
54
+ | `TX_STORE_S3_ACCESS` | `make_storage` | String, secret |
55
+ | `TX_STORE_S3_SECRET` | `make_storage` | String, secret |
56
+ | `TX_STORE_S3_DEFAULT_ACL` | `make_storage` | Object canned ACL or `none` |
57
+ | `TX_STORE_S3_BUCKET_ACL` | `make_storage` | Bucket canned ACL |
58
+ | `TX_STORE_HISTORY_KEEP` | `HistoryRetention.from_env` | Integer |
59
+ | `TX_STORE_HISTORY_MAX_AGE_DAYS` | `HistoryRetention.from_env` | Integer |
60
+
61
+ ## Secrets
62
+
63
+ - `TX_STORE_S3_ACCESS` and `TX_STORE_S3_SECRET`, and the matching arguments, are secrets: never
64
+ logged, never part of `repr`, never written to storage.
65
+ - Credentials in a URL's userinfo (`user:password@host`) and its query string are replaced by `***`
66
+ in logs, `repr`, and exception messages; which URLs are accepted →
67
+ [VALIDATION.md](VALIDATION.md#storage-urls).
68
+ - Percent-encode non-ASCII characters and `/`, `?`, `#`, and `@` in URL credentials; how an
69
+ unencoded one parses → [VALIDATION.md](VALIDATION.md#storage-urls). A URL whose authority does not
70
+ parse appears as `<unparseable URL>`; a password that parses as a host, port, and path is kept out
71
+ of logs only by the encoding.
72
+ - Object keys are names, not secrets: backends name them in logs and exception messages, `;params`
73
+ included; HTTP join refusals that name only the base →
74
+ [VALIDATION.md](VALIDATION.md#http-url-join-safety). Carry credentials in URL userinfo, netrc, or
75
+ the S3 settings, never in a key.
76
+ - Prefer the variables, or a caller's secret store, to literals in code.
77
+ - The HTTP backend sends URL userinfo as Basic auth on URLs stripped of it, so no `requests` error
78
+ or chained cause quotes the credentials.
79
+ - The HTTP backend sends `~/.netrc` (or `$NETRC`) credentials for the URL's host, overriding URL
80
+ userinfo, and routes through `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`; `REQUESTS_CA_BUNDLE`
81
+ replaces the CA store but never turns verification off. boto3 reads its own variables for S3.
82
+ - Over plain `http://`, URL userinfo and netrc credentials travel as cleartext Basic auth; the HTTP
83
+ backend warns at construction when such a URL carries userinfo, and once per instance when it
84
+ sends netrc credentials. Use `https://` for an authenticated server.
85
+
86
+ ## Maintenance
87
+
88
+ A new setting adds its keyword argument, its `_env` read with a `TX_STORE_` literal, and its row
89
+ here in the same change; `scripts/check_docs.py` fails when a read variable has no
90
+ [Environment variables](#environment-variables) row or a row names no read variable.
@@ -0,0 +1,46 @@
1
+ # Exception contract
2
+
3
+ Owns: exception classes, the conditions that raise them, and raising and chaining rules. Routing →
4
+ [AGENTS.md](AGENTS.md#canonical-sources).
5
+
6
+ ## Rules
7
+
8
+ - Contracts write a failure as `condition → ExceptionClass`.
9
+ - Backend and storage-format failures raise `StorageException` or its subclass; a caller handling
10
+ `StorageException` handles every storage failure.
11
+ - Wrap a library error (`ClientError`, `BotoCoreError`, `OSError`, `requests` errors) in
12
+ `StorageException` with `raise ... from exc`; never let it escape a public `Storage` method.
13
+ Errors from a `put_stream` source stream → [STORAGE.md](STORAGE.md#storage-abi).
14
+ - Invalid arguments and configuration raise `ValueError`, except a URL that parses but cannot name a
15
+ storage (empty, HTTP or S3 without host, `s3://` without resolved keys): `make_storage` or the
16
+ backend constructor raises `StorageException` → [STORAGE.md](STORAGE.md#construction); a malformed
17
+ `s3://` path stays `ValueError` → [Other errors](#other-errors).
18
+ - Unsupported backend operations → [Other errors](#other-errors).
19
+ - Messages state the condition and the object; secrets never appear →
20
+ [CONFIG.md](CONFIG.md#secrets).
21
+
22
+ ## Storage exceptions
23
+
24
+ | Exception | Module | Raised when |
25
+ | -- | -- | -- |
26
+ | `StorageException` | `txstore/base.py` | A backend operation, transaction, history, or lock step fails → [STORAGE.md](STORAGE.md#failure-semantics). |
27
+ | `StorageNotFoundException` | `txstore/base.py` | The named object is missing → [STORAGE.md](STORAGE.md#storage-abi); subclass of `StorageException`. |
28
+
29
+ ## Other errors
30
+
31
+ | Error | Raised when |
32
+ | -- | -- |
33
+ | `ValueError` | Unsupported URL scheme, an authority that does not parse, an `s3://` URL holding `@`, an `fs://` URL with an authority or a path that is not absolute, or a storage URL holding `?` or `#` → [VALIDATION.md](VALIDATION.md#storage-urls). |
34
+ | `ValueError` | Empty S3 bucket, or an S3 prefix with an empty segment → [VALIDATION.md](VALIDATION.md#s3-url-path-split). |
35
+ | `ValueError` | Unsafe HTTP join passed to `join_url`; `StorageHTTP` raises `StorageException` instead → [VALIDATION.md](VALIDATION.md#http-url-join-safety). |
36
+ | `ValueError` | Invalid canned ACL → [VALIDATION.md](VALIDATION.md#s3-canned-acls). |
37
+ | `ValueError` | Invalid environment value → [VALIDATION.md](VALIDATION.md#environment-values). |
38
+ | `ValueError` | Out-of-range retention → [VALIDATION.md](VALIDATION.md#history-retention). |
39
+ | `ValueError` | Out-of-range timestamp passed to `Timestamp`; one in a history record raises `StorageException` instead → [STORAGE.md](STORAGE.md#timestamps). |
40
+ | `ValueError` | Out-of-range transaction id → [STORAGE.md](STORAGE.md#lifecycle). |
41
+ | `NotImplementedError` | A read-only, write-only, or non-transactional backend is asked for an operation it lacks → [STORAGE.md](STORAGE.md#backends); not a `StorageException` → [ROADMAP.md](ROADMAP.md#known-deviations). |
42
+
43
+ ## Maintenance
44
+
45
+ A new exception class adds a row to [Storage exceptions](#storage-exceptions) in the same change;
46
+ `scripts/check_docs.py` fails when the table and the `class *Exception` definitions differ.
txstore-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Václav Vinklát
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.
txstore-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.5
2
+ Name: txstore
3
+ Version: 0.1.0
4
+ Summary: Unified object storage for Python: one interface over filesystem, S3, and HTTP, with transactions.
5
+ Project-URL: Homepage, https://github.com/vinklat/txstore
6
+ Project-URL: Source, https://github.com/vinklat/txstore
7
+ Project-URL: Issues, https://github.com/vinklat/txstore/issues
8
+ Project-URL: Documentation, https://github.com/vinklat/txstore/blob/main/docs/guides/README.md
9
+ Project-URL: Changelog, https://github.com/vinklat/txstore/blob/main/SETUP.md#upgrade-notes
10
+ Author: Václav Vinklát
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: atomic,digest,filesystem,object-storage,s3,storage,transaction
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: System :: Archiving
22
+ Classifier: Topic :: System :: Filesystems
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.13
25
+ Requires-Dist: boto3<2,>=1.40
26
+ Requires-Dist: botocore<2,>=1.40
27
+ Requires-Dist: pyxattr<0.9,>=0.8.1
28
+ Requires-Dist: requests<3,>=2.32
29
+ Description-Content-Type: text/markdown
30
+
31
+ # txstore
32
+
33
+ Unified object storage for Python: one `Storage` interface over a local filesystem, S3-compatible
34
+ buckets, and HTTP servers, selected by URL. Read, write, list, and stream objects between backends
35
+ with the same calls, and on the filesystem and S3, optionally group writes into a transaction you
36
+ commit, discard, or revert as one unit.
37
+
38
+ Current version: **0.1.0**. Planned work → [ROADMAP.md](https://github.com/vinklat/txstore/blob/v0.1.0/ROADMAP.md).
39
+
40
+ ## Capabilities
41
+
42
+ - One `Storage` interface over the filesystem (`fs://`), S3-compatible object stores (`s3://`),
43
+ read-only HTTP (`http://`, `https://`), and a write-only digest sink; `make_storage(url)` picks
44
+ the backend, so code switches storage by changing a URL.
45
+ - Get, put, list, copy, move, and metadata calls that behave the same on every backend that supports
46
+ them.
47
+ - Transactions on filesystem and S3: writes are staged as hidden footmark objects and committed,
48
+ discarded, or reverted together.
49
+ - A transaction history at the storage root with age and count retention, revert of the latest
50
+ transaction, and cleanup of orphaned footmarks.
51
+ - A cross-process writer lock: `flock` on the filesystem, conditional writes on S3.
52
+ - Streamed transfers between storages that return MD5, SHA-1, SHA-256, and SHA-512 digests in one
53
+ pass.
54
+ - S3 canned-ACL preservation across transactional copies and metadata rewrites.
55
+ - Configuration by keyword arguments or `TX_STORE_*` environment variables; no CLI coupling.
56
+
57
+ ## Quick start
58
+
59
+ ### Install
60
+
61
+ Released versions are published to [PyPI](https://pypi.org/project/txstore/):
62
+
63
+ ```bash
64
+ pip install txstore
65
+ ```
66
+
67
+ Settings → [CONFIG.md](https://github.com/vinklat/txstore/blob/v0.1.0/CONFIG.md); behavior → [STORAGE.md](https://github.com/vinklat/txstore/blob/v0.1.0/STORAGE.md).
68
+
69
+ ### Stream between backends
70
+
71
+ Every backend is a `Storage` built from a URL, so moving data between technologies is one call.
72
+ Download a web page and save it to disk, streamed in chunks rather than loaded into memory:
73
+
74
+ ```python
75
+ from txstore import make_storage
76
+
77
+ web = make_storage("https://docs.python.org/3")
78
+ disk = make_storage("fs:///srv/data", create_path=True)
79
+
80
+ digests = web.transfer("library/io.html", disk, "pages/io.html")
81
+ print(digests)
82
+ # {'MD5Sum': '...', 'SHA1': '...', 'SHA256': '...', 'SHA512': '...', 'Size': '180750'}
83
+ ```
84
+
85
+ Swap either URL for `s3://...` and the same call uploads to or downloads from a bucket. HTTP storage
86
+ is read-only and does not follow redirects; point the base URL at the final location.
87
+
88
+ ### Group writes into a transaction
89
+
90
+ Writes to the filesystem or S3 can be grouped into a transaction:
91
+
92
+ ```python
93
+ from txstore import HistoryRetention, TransactionHistory, make_storage, maybe_transaction
94
+
95
+ storage = make_storage("fs:///srv/data", create_path=True)
96
+ retention = HistoryRetention.from_env()
97
+
98
+ # Write a data file and the manifest that points to it: both are applied together on commit.
99
+ with maybe_transaction(storage, retention):
100
+ storage.put("reports/october.csv", b"day,orders\n1,42\n2,57\n")
101
+ storage.put("manifest.json", b'{"latest": "reports/october.csv"}')
102
+
103
+ # A failure inside the block discards every staged write.
104
+ try:
105
+ with maybe_transaction(storage, retention):
106
+ storage.put("reports/november.csv", b"day,orders\n1,???\n")
107
+ raise ValueError("validation failed")
108
+ except ValueError:
109
+ assert not storage.exists("reports/november.csv")
110
+
111
+ # Changed your mind later? Revert the last transaction as one unit.
112
+ history = TransactionHistory(storage)
113
+ print(
114
+ [(t.id, t.timestamp.to_json()) for t in history.load()]
115
+ ) # [(1, '2026-10-23T19:08:36.024119Z')]
116
+ history.revert_last()
117
+ assert not storage.exists("manifest.json")
118
+ ```
119
+
120
+ ### Connect to S3
121
+
122
+ On S3, pass the endpoint and bucket in the URL and the credentials as arguments or variables:
123
+
124
+ ```bash
125
+ export TX_STORE_S3_ACCESS=... TX_STORE_S3_SECRET=...
126
+ ```
127
+
128
+ ```python
129
+ storage = make_storage("s3://s3.example.com/bucket/prefix", s3_default_acl="private")
130
+ ```
131
+
132
+ ## Documentation
133
+
134
+ | Location | Audience | Authority |
135
+ | -- | -- | -- |
136
+ | `/` root contract documents | Contributors and AI agents | Canonical |
137
+ | [docs/guides/](https://github.com/vinklat/txstore/blob/v0.1.0/docs/guides/README.md) | Library users | Non-canonical guides and examples |
138
+ | [docs/development/](https://github.com/vinklat/txstore/blob/v0.1.0/docs/development/README.md) | Contributors | Non-canonical code maps, flow diagrams, explanations |
139
+ | [docs/reference/](https://github.com/vinklat/txstore/blob/v0.1.0/docs/reference/README.md) | Contributors and tooling | Non-canonical reference artifacts |
140
+
141
+ ## Canonical ownership
142
+
143
+ Domain owners and agent navigation → [AGENTS.md](https://github.com/vinklat/txstore/blob/v0.1.0/AGENTS.md#canonical-sources).
144
+
145
+ ## Technology
146
+
147
+ Python 3.13+, boto3, requests, pyxattr; pytest, moto, Ruff, mypy, and uv for development.
148
+
149
+ ## License
150
+
151
+ MIT → [LICENSE](https://github.com/vinklat/txstore/blob/v0.1.0/LICENSE).
@@ -0,0 +1,121 @@
1
+ # txstore
2
+
3
+ Unified object storage for Python: one `Storage` interface over a local filesystem, S3-compatible
4
+ buckets, and HTTP servers, selected by URL. Read, write, list, and stream objects between backends
5
+ with the same calls, and on the filesystem and S3, optionally group writes into a transaction you
6
+ commit, discard, or revert as one unit.
7
+
8
+ Current version: **0.1.0**. Planned work → [ROADMAP.md](https://github.com/vinklat/txstore/blob/v0.1.0/ROADMAP.md).
9
+
10
+ ## Capabilities
11
+
12
+ - One `Storage` interface over the filesystem (`fs://`), S3-compatible object stores (`s3://`),
13
+ read-only HTTP (`http://`, `https://`), and a write-only digest sink; `make_storage(url)` picks
14
+ the backend, so code switches storage by changing a URL.
15
+ - Get, put, list, copy, move, and metadata calls that behave the same on every backend that supports
16
+ them.
17
+ - Transactions on filesystem and S3: writes are staged as hidden footmark objects and committed,
18
+ discarded, or reverted together.
19
+ - A transaction history at the storage root with age and count retention, revert of the latest
20
+ transaction, and cleanup of orphaned footmarks.
21
+ - A cross-process writer lock: `flock` on the filesystem, conditional writes on S3.
22
+ - Streamed transfers between storages that return MD5, SHA-1, SHA-256, and SHA-512 digests in one
23
+ pass.
24
+ - S3 canned-ACL preservation across transactional copies and metadata rewrites.
25
+ - Configuration by keyword arguments or `TX_STORE_*` environment variables; no CLI coupling.
26
+
27
+ ## Quick start
28
+
29
+ ### Install
30
+
31
+ Released versions are published to [PyPI](https://pypi.org/project/txstore/):
32
+
33
+ ```bash
34
+ pip install txstore
35
+ ```
36
+
37
+ Settings → [CONFIG.md](https://github.com/vinklat/txstore/blob/v0.1.0/CONFIG.md); behavior → [STORAGE.md](https://github.com/vinklat/txstore/blob/v0.1.0/STORAGE.md).
38
+
39
+ ### Stream between backends
40
+
41
+ Every backend is a `Storage` built from a URL, so moving data between technologies is one call.
42
+ Download a web page and save it to disk, streamed in chunks rather than loaded into memory:
43
+
44
+ ```python
45
+ from txstore import make_storage
46
+
47
+ web = make_storage("https://docs.python.org/3")
48
+ disk = make_storage("fs:///srv/data", create_path=True)
49
+
50
+ digests = web.transfer("library/io.html", disk, "pages/io.html")
51
+ print(digests)
52
+ # {'MD5Sum': '...', 'SHA1': '...', 'SHA256': '...', 'SHA512': '...', 'Size': '180750'}
53
+ ```
54
+
55
+ Swap either URL for `s3://...` and the same call uploads to or downloads from a bucket. HTTP storage
56
+ is read-only and does not follow redirects; point the base URL at the final location.
57
+
58
+ ### Group writes into a transaction
59
+
60
+ Writes to the filesystem or S3 can be grouped into a transaction:
61
+
62
+ ```python
63
+ from txstore import HistoryRetention, TransactionHistory, make_storage, maybe_transaction
64
+
65
+ storage = make_storage("fs:///srv/data", create_path=True)
66
+ retention = HistoryRetention.from_env()
67
+
68
+ # Write a data file and the manifest that points to it: both are applied together on commit.
69
+ with maybe_transaction(storage, retention):
70
+ storage.put("reports/october.csv", b"day,orders\n1,42\n2,57\n")
71
+ storage.put("manifest.json", b'{"latest": "reports/october.csv"}')
72
+
73
+ # A failure inside the block discards every staged write.
74
+ try:
75
+ with maybe_transaction(storage, retention):
76
+ storage.put("reports/november.csv", b"day,orders\n1,???\n")
77
+ raise ValueError("validation failed")
78
+ except ValueError:
79
+ assert not storage.exists("reports/november.csv")
80
+
81
+ # Changed your mind later? Revert the last transaction as one unit.
82
+ history = TransactionHistory(storage)
83
+ print(
84
+ [(t.id, t.timestamp.to_json()) for t in history.load()]
85
+ ) # [(1, '2026-10-23T19:08:36.024119Z')]
86
+ history.revert_last()
87
+ assert not storage.exists("manifest.json")
88
+ ```
89
+
90
+ ### Connect to S3
91
+
92
+ On S3, pass the endpoint and bucket in the URL and the credentials as arguments or variables:
93
+
94
+ ```bash
95
+ export TX_STORE_S3_ACCESS=... TX_STORE_S3_SECRET=...
96
+ ```
97
+
98
+ ```python
99
+ storage = make_storage("s3://s3.example.com/bucket/prefix", s3_default_acl="private")
100
+ ```
101
+
102
+ ## Documentation
103
+
104
+ | Location | Audience | Authority |
105
+ | -- | -- | -- |
106
+ | `/` root contract documents | Contributors and AI agents | Canonical |
107
+ | [docs/guides/](https://github.com/vinklat/txstore/blob/v0.1.0/docs/guides/README.md) | Library users | Non-canonical guides and examples |
108
+ | [docs/development/](https://github.com/vinklat/txstore/blob/v0.1.0/docs/development/README.md) | Contributors | Non-canonical code maps, flow diagrams, explanations |
109
+ | [docs/reference/](https://github.com/vinklat/txstore/blob/v0.1.0/docs/reference/README.md) | Contributors and tooling | Non-canonical reference artifacts |
110
+
111
+ ## Canonical ownership
112
+
113
+ Domain owners and agent navigation → [AGENTS.md](https://github.com/vinklat/txstore/blob/v0.1.0/AGENTS.md#canonical-sources).
114
+
115
+ ## Technology
116
+
117
+ Python 3.13+, boto3, requests, pyxattr; pytest, moto, Ruff, mypy, and uv for development.
118
+
119
+ ## License
120
+
121
+ MIT → [LICENSE](https://github.com/vinklat/txstore/blob/v0.1.0/LICENSE).
@@ -0,0 +1,66 @@
1
+ # txstore Roadmap
2
+
3
+ ## Phases
4
+
5
+ | Phase | Status | Feature | Notes |
6
+ | -- | -- | -- | -- |
7
+ | 0.1.0 | ✅ | Initial public release | fs, S3, HTTP, and null backends; transactions, history, and retention; protobuf-free footmark codec; configuration by arguments or `TX_STORE_*` variables; Debian testing floors |
8
+ | 0.2.0 | ⏳ | Debian package for forky | `python3-txstore` built with `dh-python` and `pybuild-plugin-pyproject`, autopkgtest running pytest, DEP-14 `debian/latest` branch, `debian/watch` on GitHub `vX.Y.Z` tags, `uversionmangle` mapping a PEP 440 suffix to `~` (`0.2.0rc1` → `0.2.0~rc1`) so a pre-release sorts before its final; ITP, sponsor or Debian Python Team, upload to unstable, migrate to testing before the forky freeze |
9
+ | 1.0.0 | ⏳ | Stable API | Freeze the façade before the forky freeze so forky ships an API maintained for the whole release |
10
+
11
+ ## Ideas
12
+
13
+ Unscheduled improvements, each waiting on a trigger. Promote a row to a phase when its trigger fires
14
+ or the work is planned; drop it when it no longer applies.
15
+
16
+ | Idea | Revisit when | Area |
17
+ | -- | -- | -- |
18
+ | Generated API reference in `docs/reference/` | The façade stabilizes (1.0.0) | [STRUCTURE.md](STRUCTURE.md#layout) |
19
+ | GCS and Azure Blob backends | A user needs transactions on another object store | [STORAGE.md](STORAGE.md#backends) |
20
+ | Async API; the per-thread writer-lock claim in `history_lock` then becomes per task (a `ContextVar` of a `frozenset`) | A caller needs concurrent transfers without threads | [STORAGE.md](STORAGE.md#storage-abi) |
21
+ | Size cap or total deadline on `get` | A caller reads objects from an untrusted mirror: `get` holds the whole decoded body in memory on every backend, so a hostile HTTP origin can send an endless or gzip-bombed body; large reads use `get_stream` or `transfer` | [STORAGE.md](STORAGE.md#storage-abi) |
22
+ | Renewable S3 lock | Critical sections approach the S3 lock TTL | [STORAGE.md](STORAGE.md#locking) |
23
+ | Transactional writes as one manifest object | Partial commits on unreachable backends cost real data | [STORAGE.md](STORAGE.md#failure-semantics) |
24
+ | Split `Storage` into readable, writable, and transactional interfaces | Before freezing the façade (1.0.0): the read-only and write-only mixins exist mainly to stub half of a 17-method ABC, and `TransactionNotSupportedMixin` only stubs transaction names on non-transactional backends; the split also adds the public transactional type that `is_transactional` narrows to, leaving `FootmarkMixin` an implementation mixin | [STORAGE.md](STORAGE.md#storage-abi) |
25
+ | Inject `S3Client` into `StorageS3`; no network I/O in the constructor | A caller needs a fake client or lazy bucket creation; `create_bucket` runs in `__init__` today | [STORAGE.md](STORAGE.md#construction) |
26
+ | Bind the bucket inside `S3Client` | The `S3Client` surface is next refactored: every call passes the same bucket, and `_LockObject` and `is_public` carry it too; `_LockObject` and its eight helpers then become one `_S3Lock` class | [STRUCTURE.md](STRUCTURE.md#layout) |
27
+ | Move the canned-ACL choice sets, parsers, and public-ACL set beside the `S3Client` literals, behind an `AclPolicy` holding the default, bucket, reserved, and verify-public settings | The row above lands: ACL rules span `_s3_client`, `storage_s3`, `_s3_acl`, and `factory`, the four settings always travel together, and `acl: str \| None` means an octal mode on `fs` and a canned ACL on `s3` | [STORAGE.md](STORAGE.md#s3-acl-rules) |
28
+ | Public commit hook: `maybe_transaction` calls `transaction_commit()`, which keeps its `bool` result, and `_commit_listed` goes away | A subclass needs a commit hook, or before freezing the façade (1.0.0): today the hook is the private `_commit_listed`. Lands with or after the row below, which retires the `hidden` listing `_commit_listed` hands the prune, so that listing never becomes public; a result tuple would also be truthy whatever `changed` is | [STORAGE.md](STORAGE.md#backend-primitives) |
29
+ | Commit and discard from the transaction's own footmarks, not a full listing; auto-prune lists only when a record expires | Roots reach ~100k keys: every recorded write lists every key under the root while holding `history_lock`; this also retires the commit listing the prune reuses | [STORAGE.md](STORAGE.md#backend-primitives) |
30
+ | Footmarks under a dedicated namespace prefix, so commit, revert, prune, and sweeps list only footmarks | The row above is not enough: a footmark layout change, cheapest before 1.0.0 | [STORAGE.md](STORAGE.md#footmarks) |
31
+ | Mixin-owned public read, write, delete, and metadata pipeline; backends supply primitives only | With the interface split (1.0.0): backends still sequence path validation, overlay call, ACL sidecar, and metrics by hand; `StorageFS` calls `_validate_path` in every verb, `_obj_full` does not confine, three resolve-under-root checks differ (`validate_path`, `_obj_check_reserved`, `_obj_checked_footmark`), and `url.join_path` keeps an unused `no_root=True` branch | [STORAGE.md](STORAGE.md#backend-primitives) |
32
+ | Reclaim as pure classifiers plus one path delete, with a separate prune-plan type | With the interface split (1.0.0): each `find_*` / forget pair re-classifies the paths it is handed, and `PruneResult` is both the plan and the outcome; `_prune_locked` / `_apply_prune` then move from `TransactionHistory` onto the plan type | [STORAGE.md](STORAGE.md#lifecycle) |
33
+ | `TransactionHistory` takes a typed transactional port instead of any `Storage` | With the interface split (1.0.0): every history entry point (`history_lock`, `maybe_transaction`, and `TransactionHistory.load` / `save` / `revert_last` / `pop_last` / `prune`) re-checks `is_transactional`, `append` / `save` rely on the caller holding `history_lock`, and history reaches the private `_commit_listed`, `_transaction_abandon`, `_obj_lock`, `_obj_get_reserved`, and `_obj_put_reserved`; a raw `transaction_begin` auto-increments the instance id, which ignores history, while `maybe_transaction` takes `next_id` | [STORAGE.md](STORAGE.md#operations) |
34
+ | Per-transaction object attributes owned by `FootmarkMixin` | The pipeline above lands: `StorageS3` keeps the ACL sidecar and three `in_transaction()` branches itself | [STORAGE.md](STORAGE.md#s3-acl-rules) |
35
+ | Transaction overlay as an object `transaction_begin` installs and commit, discard, or revert remove, over a typed primitives port | The rows above land (1.0.0): seven overlay helpers branch on `in_transaction()`, `transaction_begin` resets the staged-footmark ledger by hand, and `FootmarkMixin` holds the lifecycle, overlay, and primitives in one class | [STORAGE.md](STORAGE.md#backend-primitives) |
36
+ | The overlay records which of its own footmarks exist, so a staged write probes only the original | S3 syncs of thousands of puts: a staged put of a new key costs three HEAD requests (BACKUP, original, DELETE marker) under `history_lock` | [STORAGE.md](STORAGE.md#footmarks) |
37
+ | A parsed storage-URL value type that renders redacted in `str`, `repr`, and messages, and owns the per-scheme host and userinfo rules | Before freezing the façade (1.0.0), a URL secret leaking at a new site, a third URL backend, or a new authority check outside `url.py`: `parse_storage_url` returns a public tuple, so redaction is applied per call site, `make_storage` and both URL backends re-parse the authority from strings, and each backend constructor repeats the query, host, and userinfo-stripping steps | [VALIDATION.md](VALIDATION.md#storage-urls) |
38
+ | A `Footmarks` object on `TransactionModel` with `path_for(operation, path)` and `operation_of(token)`, still a `Sequence[str]` | With the overlay object above: two sites index or enumerate the token tuple by `ObjectOperation` value | [STORAGE.md](STORAGE.md#footmarks) |
39
+ | A pooled `requests.Session` per `StorageHTTP` instance, closed through a backend `close()` lifecycle | A caller syncs many small objects from an https mirror: every request opens a new connection and TLS handshake, since `requests.request` builds a throwaway session; netrc stays read per request, so an edit takes effect at once | [STORAGE.md](STORAGE.md#backends) |
40
+ | Lazy probe that the S3 endpoint enforces conditional writes | A user reports lost writes on a non-AWS endpoint; one probe on first lock, never in the constructor | [STORAGE.md](STORAGE.md#locking) |
41
+ | `StorageFS` without symlink resolution on mutation or read | A shared root with untrusted writers: a directory swapped for a link between `validate_path` and a write, read, or unlink is followed; closing it needs one root object that opens, unlinks, renames, and stats by relative path through `openat`-style descriptors, replacing per-call-site checks | [VALIDATION.md](VALIDATION.md#filename-safety) |
42
+ | `StorageFS` refuses a hard-linked object, footmark, or `.transactions.lock` | A shared root with untrusted writers where `fs.protected_hardlinks` is off or absent (macOS, the BSDs): a hard link to a file outside the root is read as an object and copied by commit and revert, and a linked `.transactions.lock` puts the history `flock` on the outside file, a lock denial of service but no write; refusing `st_nlink > 1` would also refuse roots backed up with hard-link snapshots | [VALIDATION.md](VALIDATION.md#filename-safety) |
43
+ | `StorageFS` commit pre-flight that refuses a staged file whose path became a directory, and staged `mkdir` / `rmdir` | A caller stages a file at `d` and then `d/child` or `mkdir("d")` in one transaction: commit fails part-way in set order, and `mkdir` / `rmdir` act immediately | [STORAGE.md](STORAGE.md#failure-semantics) |
44
+ | Reclaim `StorageFS` write temp files left by a crash | Leftovers accumulate; they are hidden from `list()` but never deleted | [STORAGE.md](STORAGE.md#backend-primitives) |
45
+ | Fixed `0600` mode for `StorageFS` history and lock files | A root is shared with users who must not read the audit log; today the umask applies as for objects | [VALIDATION.md](VALIDATION.md#filename-safety) |
46
+ | Maintenance CLI for transactional roots: retention prune, orphan check and sweep, temp-file cleanup, history inspection | Operators run these from ad-hoc scripts; the CLI needs its own entry point and a layout decision | [STORAGE.md](STORAGE.md#retention) |
47
+
48
+ ## Known deviations
49
+
50
+ Implementation that does not yet meet a root contract. Contracts link here instead of carrying
51
+ TODOs; remove a row when the code meets the rule.
52
+
53
+ | Contract | Deviation | Source |
54
+ | -- | -- | -- |
55
+ | [STORAGE.md](STORAGE.md#recording-rules) | History append after commit, `save` after revert, and a prune's forget-then-rewrite are not atomic with the footmark lifecycle; a crash between them leaves committed objects without a record, or a record whose footmarks are gone. | `txstore/history.py` `maybe_transaction`, `revert_last`, `prune` |
56
+ | [STORAGE.md](STORAGE.md#lifecycle) | Revert infers a created object from a PUT footmark without its BACKUP, so a BACKUP removed outside txstore (by hand, an S3 lifecycle rule, a peer's orphan sweep during a raw transaction) makes revert delete the original; an explicit created marker in the footmark set would close it. | `txstore/transaction.py` `transaction_revert` |
57
+ | [STORAGE.md](STORAGE.md#lifecycle) | Off `fs`, commit checks a staged BACKUP footmark only against its listing, so one removed outside txstore after the listing goes unnoticed and commit overwrites the only other copy of its original. | `txstore/transaction.py` `_commit_listed`, `_obj_checked_footmark` |
58
+ | [STORAGE.md](STORAGE.md#lifecycle) | Commit takes a staged footmark the listing lacks for an alias once `_obj_exists` finds it, so on `fs` one replaced outside txstore by a directory of its name is skipped instead of refused. | `txstore/transaction.py` `_commit_listed` |
59
+ | [STORAGE.md](STORAGE.md#failure-semantics) | Transaction atomicity is best-effort when the backend fails mid-commit or mid-discard; remote state may be left partially updated. | `txstore/transaction.py` `FootmarkMixin` |
60
+ | [STORAGE.md](STORAGE.md#failure-semantics) | `maybe_transaction` reads the history before beginning, so a malformed `.transactions.json` blocks every transactional write; nothing bypasses history recording. | `txstore/history.py` `maybe_transaction` |
61
+ | [STORAGE.md](STORAGE.md#s3-acl-rules) | A copy without a sidecar entry carries only a group canned ACL; `private`, `bucket-owner-*`, or `aws-exec-read` on a key not written in the transaction is reset by a revert, which always copies outside a transaction, or by any other copy outside a transaction. | `txstore/storage_s3.py` `_obj_copy` |
62
+ | [STORAGE.md](STORAGE.md#locking) | `history_lock` spans the whole `maybe_transaction` body, including bulk transfers, and the whole `prune` run; a peer writer that waits past the S3 acquire timeout fails instead of queueing. | `txstore/history.py` `maybe_transaction`, `prune` |
63
+ | [STORAGE.md](STORAGE.md#locking) | Expiry is judged by the breaker's clock against the holder's stamp, so a clock skew approaching the 12 h TTL breaks a live lock early or keeps an expired one. | `txstore/_s3_lock.py` `_body_is_breakable` |
64
+ | [STORAGE.md](STORAGE.md#locking) | The S3 lock stamps `expires_at` once at acquire and never renews it, so a critical section outliving the TTL can be taken over while its holder still runs; a crashed holder must be cleared by deleting `.transactions.lock`. | `txstore/_s3_lock.py` `s3_lock` |
65
+ | [STORAGE.md](STORAGE.md#locking) | A lock write that commits while every botocore attempt fails in transport aborts the acquire with the body left inside its TTL; peers wait for it to expire. | `txstore/_s3_lock.py` `_try_create`, `_try_take_over` |
66
+ | [EXCEPTIONS.md](EXCEPTIONS.md#rules) | `NotImplementedError` from a capability stub is not a `StorageException`, so a caller handling only `StorageException` does not catch it. | `txstore/_capabilities.py` |