xtr-storage 3.0.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 (87) hide show
  1. xtr_storage-3.0.0/LICENSE +21 -0
  2. xtr_storage-3.0.0/PKG-INFO +475 -0
  3. xtr_storage-3.0.0/README.md +446 -0
  4. xtr_storage-3.0.0/pyproject.toml +173 -0
  5. xtr_storage-3.0.0/pyproject.toml.orig +180 -0
  6. xtr_storage-3.0.0/src/xtr_storage/.agents/skills/xtr-storage/SKILL.md +207 -0
  7. xtr_storage-3.0.0/src/xtr_storage/__init__.py +158 -0
  8. xtr_storage-3.0.0/src/xtr_storage/adapter/__init__.py +29 -0
  9. xtr_storage-3.0.0/src/xtr_storage/adapter/_fsspec_bridge.py +212 -0
  10. xtr_storage-3.0.0/src/xtr_storage/adapter/_object_store.py +108 -0
  11. xtr_storage-3.0.0/src/xtr_storage/adapter/fsspec_adapter.py +528 -0
  12. xtr_storage-3.0.0/src/xtr_storage/adapter/gcs_adapter.py +325 -0
  13. xtr_storage-3.0.0/src/xtr_storage/adapter/generic_fsspec_adapter.py +60 -0
  14. xtr_storage-3.0.0/src/xtr_storage/adapter/in_memory_adapter.py +174 -0
  15. xtr_storage-3.0.0/src/xtr_storage/adapter/local_adapter.py +259 -0
  16. xtr_storage-3.0.0/src/xtr_storage/adapter/path_prefixed_adapter.py +354 -0
  17. xtr_storage-3.0.0/src/xtr_storage/adapter/portable_visibility_converter.py +98 -0
  18. xtr_storage-3.0.0/src/xtr_storage/adapter/read_only_adapter.py +231 -0
  19. xtr_storage-3.0.0/src/xtr_storage/adapter/s3_adapter.py +441 -0
  20. xtr_storage-3.0.0/src/xtr_storage/adapter/storage_adapter_interface.py +217 -0
  21. xtr_storage-3.0.0/src/xtr_storage/adapter/visibility_converter_interface.py +88 -0
  22. xtr_storage-3.0.0/src/xtr_storage/bundle/__init__.py +28 -0
  23. xtr_storage-3.0.0/src/xtr_storage/bundle/adapter_configs.py +227 -0
  24. xtr_storage-3.0.0/src/xtr_storage/bundle/storage_bundle.py +328 -0
  25. xtr_storage-3.0.0/src/xtr_storage/bundle/storage_config.py +103 -0
  26. xtr_storage-3.0.0/src/xtr_storage/bundle/storage_definition.py +85 -0
  27. xtr_storage-3.0.0/src/xtr_storage/checksum_provider_interface.py +42 -0
  28. xtr_storage-3.0.0/src/xtr_storage/config.py +260 -0
  29. xtr_storage-3.0.0/src/xtr_storage/directory_attributes.py +80 -0
  30. xtr_storage-3.0.0/src/xtr_storage/directory_listing.py +149 -0
  31. xtr_storage-3.0.0/src/xtr_storage/exception/__init__.py +75 -0
  32. xtr_storage-3.0.0/src/xtr_storage/exception/checksum_algorithm_not_supported_error.py +29 -0
  33. xtr_storage-3.0.0/src/xtr_storage/exception/corrupted_path_detected_error.py +27 -0
  34. xtr_storage-3.0.0/src/xtr_storage/exception/feature_not_supported_error.py +39 -0
  35. xtr_storage-3.0.0/src/xtr_storage/exception/invalid_argument_error.py +29 -0
  36. xtr_storage-3.0.0/src/xtr_storage/exception/invalid_stream_error.py +30 -0
  37. xtr_storage-3.0.0/src/xtr_storage/exception/invalid_visibility_error.py +30 -0
  38. xtr_storage-3.0.0/src/xtr_storage/exception/missing_backend_error.py +34 -0
  39. xtr_storage-3.0.0/src/xtr_storage/exception/path_traversal_detected_error.py +27 -0
  40. xtr_storage-3.0.0/src/xtr_storage/exception/storage_error.py +19 -0
  41. xtr_storage-3.0.0/src/xtr_storage/exception/storage_operation_failed_error.py +32 -0
  42. xtr_storage-3.0.0/src/xtr_storage/exception/symbolic_link_encountered_error.py +26 -0
  43. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_check_directory_existence_error.py +24 -0
  44. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_check_existence_error.py +32 -0
  45. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_check_file_existence_error.py +24 -0
  46. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_copy_file_error.py +39 -0
  47. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_create_directory_error.py +32 -0
  48. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_delete_directory_error.py +36 -0
  49. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_delete_file_error.py +35 -0
  50. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_generate_public_url_error.py +30 -0
  51. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_generate_temporary_url_error.py +30 -0
  52. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_list_contents_error.py +37 -0
  53. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_move_file_error.py +39 -0
  54. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_provide_checksum_error.py +30 -0
  55. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_read_file_error.py +36 -0
  56. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_resolve_mount_error.py +30 -0
  57. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_retrieve_metadata_error.py +61 -0
  58. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_set_visibility_error.py +36 -0
  59. xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_write_file_error.py +35 -0
  60. xtr_storage-3.0.0/src/xtr_storage/feature.py +23 -0
  61. xtr_storage-3.0.0/src/xtr_storage/file_attributes.py +88 -0
  62. xtr_storage-3.0.0/src/xtr_storage/identical_path_policy.py +27 -0
  63. xtr_storage-3.0.0/src/xtr_storage/link_handling.py +25 -0
  64. xtr_storage-3.0.0/src/xtr_storage/mime_type/__init__.py +8 -0
  65. xtr_storage-3.0.0/src/xtr_storage/mime_type/extension_mime_type_detector.py +53 -0
  66. xtr_storage-3.0.0/src/xtr_storage/mime_type/mime_type_detector_interface.py +32 -0
  67. xtr_storage-3.0.0/src/xtr_storage/mount_manager.py +461 -0
  68. xtr_storage-3.0.0/src/xtr_storage/operation.py +31 -0
  69. xtr_storage-3.0.0/src/xtr_storage/path/__init__.py +9 -0
  70. xtr_storage-3.0.0/src/xtr_storage/path/path_normalizer_interface.py +33 -0
  71. xtr_storage-3.0.0/src/xtr_storage/path/path_prefixer.py +66 -0
  72. xtr_storage-3.0.0/src/xtr_storage/path/whitespace_path_normalizer.py +68 -0
  73. xtr_storage-3.0.0/src/xtr_storage/py.typed +0 -0
  74. xtr_storage-3.0.0/src/xtr_storage/recipe/__init__.py +15 -0
  75. xtr_storage-3.0.0/src/xtr_storage/recipe/manifest.toml +21 -0
  76. xtr_storage-3.0.0/src/xtr_storage/storage.py +480 -0
  77. xtr_storage-3.0.0/src/xtr_storage/storage_attributes.py +18 -0
  78. xtr_storage-3.0.0/src/xtr_storage/storage_operator_interface.py +91 -0
  79. xtr_storage-3.0.0/src/xtr_storage/storage_reader_interface.py +189 -0
  80. xtr_storage-3.0.0/src/xtr_storage/storage_writer_interface.py +172 -0
  81. xtr_storage-3.0.0/src/xtr_storage/url_generation/__init__.py +17 -0
  82. xtr_storage-3.0.0/src/xtr_storage/url_generation/chained_public_url_generator.py +66 -0
  83. xtr_storage-3.0.0/src/xtr_storage/url_generation/prefix_public_url_generator.py +54 -0
  84. xtr_storage-3.0.0/src/xtr_storage/url_generation/public_url_generator_interface.py +41 -0
  85. xtr_storage-3.0.0/src/xtr_storage/url_generation/sharded_prefix_public_url_generator.py +68 -0
  86. xtr_storage-3.0.0/src/xtr_storage/url_generation/temporary_url_generator_interface.py +45 -0
  87. xtr_storage-3.0.0/src/xtr_storage/visibility.py +48 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
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.
@@ -0,0 +1,475 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-storage
3
+ Version: 3.0.0
4
+ Summary: Files on local disk, in memory or in object stores, behind one async interface.
5
+ Keywords: storage,files,s3,gcs,asyncio
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: fsspec>=2026.6
19
+ Requires-Dist: typing-extensions>=4.4
20
+ Requires-Dist: xtr-dependency-injection>=3.0,<4 ; extra == 'di'
21
+ Requires-Dist: xtr-service-contracts>=3.0,<4 ; extra == 'di'
22
+ Requires-Dist: gcsfs>=2026.6 ; extra == 'gcs'
23
+ Requires-Dist: s3fs>=2026.6 ; extra == 's3'
24
+ Requires-Python: >=3.11
25
+ Provides-Extra: di
26
+ Provides-Extra: gcs
27
+ Provides-Extra: s3
28
+ Description-Content-Type: text/markdown
29
+
30
+ <div align="center">
31
+
32
+ # xtr-storage
33
+
34
+ **Files on local disk, in memory or in object stores, behind one async interface.**
35
+
36
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
37
+ <img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
38
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
39
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
40
+
41
+ </div>
42
+
43
+ ---
44
+
45
+ ## Why?
46
+
47
+ Code that stores a file usually names the place in the same breath: an open at a
48
+ path, a client call to a bucket. Move the file somewhere else — a disk to a
49
+ bucket, a bucket to a dictionary in a test — and every one of those calls has to
50
+ be rewritten, because reading a local file and reading an object share no shape.
51
+
52
+ This package makes the place a constructor argument. A `Storage` wraps one
53
+ adapter and turns paths into reads, writes, listings, copies and moves; the
54
+ adapter decides where the bytes live. The code that stores files is written
55
+ once and moved between backends by swapping the adapter it is handed.
56
+
57
+ - 📁 **One async contract** — `read`, `write`, `delete`, `list_contents`, `copy`,
58
+ `move`, metadata and urls, every call awaited.
59
+ - 🔌 **Seven adapters** — local disk, in-process memory, Amazon S3, Google Cloud
60
+ Storage, any other backend by protocol, plus read-only and path-prefixed
61
+ wrappers around any of them.
62
+ - 🧭 **A mount manager** — several storages behind one, each reached by the name
63
+ a location starts with (`"uploads://avatars/1.png"`).
64
+ - 🚫 **Honest about limits** — a backend that cannot do a thing raises a clear
65
+ `FeatureNotSupportedError`, never a silent wrong answer.
66
+ - 🧩 **A bundle** — named storages configured in Python and injected anywhere,
67
+ with a zero-config default that touches no disk until first use.
68
+
69
+ ```python
70
+ from xtr_storage import LocalAdapter, Storage
71
+
72
+ storage = Storage(LocalAdapter("var/storage"))
73
+
74
+ await storage.write("reports/2026.txt", b"hello")
75
+ report = await storage.read("reports/2026.txt")
76
+ ```
77
+
78
+ ## Install
79
+
80
+ ```sh
81
+ uv add xtr-storage
82
+ uv add "xtr-storage[s3]" # S3Adapter, on the s3fs backend
83
+ uv add "xtr-storage[gcs]" # GcsAdapter, on the gcsfs backend
84
+ uv add "xtr-storage[di]" # the bundle for xtr-dependency-injection
85
+ ```
86
+
87
+ Requires Python 3.11+. Every adapter is built on `fsspec`, a required
88
+ dependency; the S3 and Google Cloud backends are the optional extras above. An
89
+ adapter whose extra is missing raises `MissingBackendError` naming the extra to
90
+ install, rather than failing on an obscure import.
91
+
92
+ ## Quick start
93
+
94
+ A storage is an adapter and, optionally, standing defaults every call starts
95
+ from. The in-memory adapter keeps files in a dictionary — nothing to clean up —
96
+ and the local adapter keeps them under a directory, created on first write:
97
+
98
+ ```python
99
+ import anyio
100
+
101
+ from xtr_storage import InMemoryAdapter, LocalAdapter, Storage, Visibility
102
+
103
+
104
+ async def main() -> None:
105
+ memory = Storage(InMemoryAdapter())
106
+
107
+ await memory.write("notes/todo.txt", b"buy milk", {"visibility": Visibility.PUBLIC})
108
+ print((await memory.read("notes/todo.txt")).decode()) # buy milk
109
+ print(await memory.file_exists("notes/todo.txt")) # True
110
+ print(await memory.mime_type("notes/todo.txt")) # text/plain
111
+
112
+ async for chunk in memory.read_stream("notes/todo.txt"):
113
+ print(len(chunk)) # 8
114
+
115
+ disk = Storage(LocalAdapter("var/storage"))
116
+ await disk.write("greetings/hello.txt", b"hi there")
117
+ await memory.copy("notes/todo.txt", "notes/copy.txt")
118
+
119
+ listing = memory.list_contents("notes", deep=True)
120
+ print(sorted([entry.path async for entry in listing])) # ['notes/copy.txt', 'notes/todo.txt']
121
+
122
+ await disk.delete("greetings/hello.txt")
123
+ await memory.close()
124
+ await disk.close()
125
+
126
+
127
+ anyio.run(main)
128
+ ```
129
+
130
+ A call's own options are laid over the storage's standing defaults for that call
131
+ alone, so one upload can be public without changing the next. A `Storage` is an
132
+ async context manager too: `async with Storage(adapter) as storage:` closes the
133
+ adapter on the way out.
134
+
135
+ ## Adapters
136
+
137
+ Every adapter satisfies the same `StorageAdapterInterface`. They differ only in
138
+ where the bytes live and which of four capabilities they can offer: setting and
139
+ reading **visibility**, handing back a **checksum**, composing a lasting
140
+ **public url**, and signing a **temporary url**. A capability a backend lacks is
141
+ either worked around by the storage (a checksum is computed by hashing the bytes
142
+ when the backend keeps none) or refused with a clear error.
143
+
144
+ ### `LocalAdapter`
145
+
146
+ ```python
147
+ from xtr_storage import LocalAdapter, LinkHandling
148
+
149
+ adapter = LocalAdapter("var/storage", link_handling=LinkHandling.SKIP)
150
+ ```
151
+
152
+ Files under one directory on this machine, everything below it and nothing
153
+ above. Visibility becomes permission bits through a converter the deployment may
154
+ replace; directories a write needs are created with the mode that converter
155
+ names, not the process umask. Symbolic links in a listing are either skipped or
156
+ refused. Nothing is touched until the first operation — the root is created by
157
+ the first write.
158
+
159
+ | Visibility | Checksum | Public url | Temporary url |
160
+ |---|---|---|---|
161
+ | ✓ permission bits | ✓ by hashing the bytes | only with a configured generator, else `UnableToGeneratePublicUrlError` | `UnableToGenerateTemporaryUrlError` unless a generator is injected |
162
+
163
+ ### `InMemoryAdapter`
164
+
165
+ ```python
166
+ from xtr_storage import InMemoryAdapter, Visibility
167
+
168
+ adapter = InMemoryAdapter(default_visibility=Visibility.PRIVATE)
169
+ ```
170
+
171
+ Files in a dictionary that vanish with the process, each with a visibility
172
+ remembered beside it. Two in-memory adapters in one process share no store, so a
173
+ test that writes has nothing to undo. What a test reaches for, and what an
174
+ application uses to hand bytes between two steps of one request.
175
+
176
+ | Visibility | Checksum | Public url | Temporary url |
177
+ |---|---|---|---|
178
+ | ✓ remembered per file | ✓ by hashing the bytes | only with a configured generator, else `UnableToGeneratePublicUrlError` | `UnableToGenerateTemporaryUrlError` unless a generator is injected |
179
+
180
+ ### `S3Adapter`
181
+
182
+ ```python
183
+ from xtr_storage import S3Adapter, Storage
184
+
185
+ storage = Storage(S3Adapter("uploads", prefix="avatars", region="eu-west-1"))
186
+ ```
187
+
188
+ An S3 bucket, and an optional key prefix within it, as one storage — through the
189
+ `s3` extra. A directory is a zero-byte marker object, hidden from listings.
190
+ Visibility is a canned access list, public-read or private. A checksum is the
191
+ entity tag the store already holds, handed out for `"etag"`; any other algorithm
192
+ falls back to hashing. A file has a lasting public url composed from the bucket
193
+ and region (or the endpoint) and a temporary signed one the backend produces.
194
+ Credentials handed in are kept for the first client build and never reach a
195
+ message. Nothing is opened by construction.
196
+
197
+ | Visibility | Checksum | Public url | Temporary url |
198
+ |---|---|---|---|
199
+ | ✓ access list | ✓ `"etag"` from the store, else by hashing | ✓ composed from bucket and region or endpoint | ✓ signed by the backend |
200
+
201
+ ### `GcsAdapter`
202
+
203
+ ```python
204
+ from xtr_storage import GcsAdapter, Storage
205
+
206
+ storage = Storage(GcsAdapter("uploads", prefix="avatars"))
207
+ ```
208
+
209
+ A Google Cloud Storage bucket — through the `gcs` extra. Directories are the same
210
+ zero-byte fiction. Checksums, public urls and signed urls are real; the native
211
+ `"md5"` and `"crc32c"` checksums are read from the object, other algorithms fall
212
+ back to hashing. Visibility is refused this version, because the store offers no
213
+ single answer to map onto. Nothing is opened by construction.
214
+
215
+ | Visibility | Checksum | Public url | Temporary url |
216
+ |---|---|---|---|
217
+ | ✗ raises `FeatureNotSupportedError` | ✓ `"md5"`/`"crc32c"` from the object, else by hashing | ✓ composed from bucket or endpoint | ✓ signed by the backend |
218
+
219
+ ### `GenericFsspecAdapter`
220
+
221
+ ```python
222
+ from fsspec.implementations.memory import MemoryFileSystem
223
+ from xtr_storage import GenericFsspecAdapter, Storage
224
+
225
+ storage = Storage(GenericFsspecAdapter(MemoryFileSystem(), root="data"))
226
+ ```
227
+
228
+ Any backend this library has no named adapter for. Hand it a filesystem instance
229
+ and a root, and it wraps that instance behind the same contract.
230
+
231
+ | Visibility | Checksum | Public url | Temporary url |
232
+ |---|---|---|---|
233
+ | ✗ raises `FeatureNotSupportedError` | ✓ by hashing the bytes | only with a configured generator, else `UnableToGeneratePublicUrlError` | `UnableToGenerateTemporaryUrlError` unless a generator is injected |
234
+
235
+ ### `ReadOnlyAdapter` and `PathPrefixedAdapter`
236
+
237
+ ```python
238
+ from xtr_storage import PathPrefixedAdapter, ReadOnlyAdapter, S3Adapter, Storage
239
+
240
+ published = Storage(ReadOnlyAdapter(S3Adapter("assets")))
241
+ tenant = Storage(PathPrefixedAdapter(S3Adapter("uploads"), "tenant-42"))
242
+ ```
243
+
244
+ Two wrappers around any adapter. `ReadOnlyAdapter` delegates every read and
245
+ capability and refuses every write with its own operation error. `PathPrefixedAdapter`
246
+ roots an adapter under a prefix — every path is prefixed going in, stripped out
247
+ of listings coming back, and errors carry the caller-facing path. An empty
248
+ prefix is refused.
249
+
250
+ ## Paths
251
+
252
+ Every path a caller gives is normalized before an adapter ever sees it, to the
253
+ one form the backend is shown: backslashes become forward slashes, `.` segments
254
+ and empty segments drop, and `..` pops the segment before it. A path with a
255
+ control or zero-width character is refused with `CorruptedPathDetectedError`. A
256
+ `..` that would climb past the root is refused with `PathTraversalDetectedError`
257
+ when traversal is disallowed — allowed by default, set
258
+ `allow_relative_path_traversal=False` in the config to forbid it.
259
+
260
+ ## Visibility
261
+
262
+ `Visibility` has two members, `PUBLIC` and `PRIVATE`, and each *is* its string
263
+ (`"public"`, `"private"`), so an option mapping or an environment variable
264
+ carries the word and is understood without a conversion step. An adapter
265
+ translates the two into whatever its backend uses — permission bits, an access
266
+ list — and one that cannot say so raises rather than guess. Pass a visibility as
267
+ a write option, or set it after the fact:
268
+
269
+ ```python
270
+ await storage.write("logo.svg", data, {"visibility": "public"})
271
+ await storage.set_visibility("logo.svg", Visibility.PRIVATE)
272
+ print(await storage.visibility("logo.svg")) # Visibility.PRIVATE
273
+ ```
274
+
275
+ ## Streams
276
+
277
+ Large files never have to land in memory whole. `read_stream` yields the bytes
278
+ in chunks; `write_stream` accepts an async iterable of bytes, a plain iterable,
279
+ or a binary file object, spooling it to the backend a chunk at a time. A
280
+ seekable file object is rewound first; anything else raises `InvalidStreamError`.
281
+
282
+ ```python
283
+ async def chunks() -> AsyncIterator[bytes]:
284
+ yield b"part one"
285
+ yield b"part two"
286
+
287
+
288
+ await storage.write_stream("big.bin", chunks())
289
+ async for chunk in storage.read_stream("big.bin"):
290
+ ...
291
+ ```
292
+
293
+ ## Mount manager
294
+
295
+ A `MountManager` puts several storages behind one, each reached by the name it
296
+ was mounted at. Every location reads `<name>://<path>`; there is no default
297
+ mount, so a location that went nowhere is an error rather than a silent guess.
298
+ A copy or move within one storage is delegated; across two, the manager streams
299
+ the bytes from one to the other and carries the visibility when both support it.
300
+
301
+ ```python
302
+ from xtr_storage import InMemoryAdapter, MountManager, Storage
303
+
304
+ files = MountManager(
305
+ {
306
+ "uploads": Storage(InMemoryAdapter()),
307
+ "reports": Storage(InMemoryAdapter()),
308
+ }
309
+ )
310
+
311
+ await files.write("uploads://a.txt", b"hi")
312
+ await files.copy("uploads://a.txt", "reports://a.txt")
313
+ async for entry in files.list_contents("reports://", deep=True):
314
+ print(entry.path) # reports://a.txt
315
+
316
+ await files.close() # closes every mounted storage
317
+ ```
318
+
319
+ An unknown mount, a missing `://`, or an empty name raises
320
+ `UnableToResolveMountError`.
321
+
322
+ ## Use in an application
323
+
324
+ Everything adding this package to an application on
325
+ [xtr-dependency-injection](../xtr-dependency-injection) takes — and, read
326
+ backwards, what removing it undoes.
327
+
328
+ - **Install** — `uv add "xtr-storage[di]"`; add `s3` or `gcs` for the object
329
+ stores you reach.
330
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* and *Ignore*
331
+ steps below: it lists `StorageBundle` and ignores `/var/storage/`. There is no
332
+ config file or environment to write.
333
+ - **Activate** — `StorageBundle: {"all": True}` in `BUNDLES` in
334
+ `<app>/bundles.py`, imported from `xtr_storage.bundle`.
335
+ - **Brings along** — nothing; the bundle requires no other.
336
+ - **Configure** — optional: with no configuration there is one `default` storage
337
+ on local files under `var/storage`. Named storages go in
338
+ `<app>/config/storage.py`, a `@configure` function returning `StorageConfig`
339
+ — see [Kernel / bundle](#kernel--bundle).
340
+ - **Environment** — nothing required. Credentials and directories given as
341
+ `env(...)` must be set when the application boots: booting checks every
342
+ storage.
343
+ - **Ignore** — `var/storage/`: where the default storage keeps files, under the
344
+ project.
345
+ - **Remove** — drop the `BUNDLES` entry, delete `<app>/config/storage.py`, then
346
+ `uv remove xtr-storage`.
347
+ - **Check** — `debug:bundles` shows `storage` as `listed` and `active`.
348
+
349
+ ## Kernel / bundle
350
+
351
+ With [xtr-dependency-injection](../xtr-dependency-injection), list the bundle and
352
+ name the storages:
353
+
354
+ ```python
355
+ # app/bundles.py
356
+ from xtr_storage.bundle import StorageBundle
357
+
358
+ BUNDLES = {StorageBundle: {"all": True}}
359
+ ```
360
+
361
+ ```python
362
+ # app/config/storage.py
363
+ from xtr_dependency_injection import configure, env
364
+ from xtr_storage.bundle import LocalAdapterConfig, S3AdapterConfig, StorageConfig, StorageDefinition
365
+
366
+
367
+ @configure
368
+ def storage() -> StorageConfig:
369
+ return StorageConfig(
370
+ storages={
371
+ "default": LocalAdapterConfig(),
372
+ "uploads": StorageDefinition(
373
+ adapter=S3AdapterConfig(
374
+ bucket=env("UPLOADS_BUCKET"),
375
+ region=env("AWS_REGION"),
376
+ key=env("AWS_ACCESS_KEY_ID"),
377
+ secret=env("AWS_SECRET_ACCESS_KEY"),
378
+ ),
379
+ prefix="avatars",
380
+ visibility="public",
381
+ ),
382
+ },
383
+ )
384
+ ```
385
+
386
+ Every storage is registered under `StorageOperatorInterface`,
387
+ `StorageReaderInterface` and `StorageWriterInterface`, qualified by its name; the
388
+ one named `"default"` is provided without a qualifier too. A `MountManager` over
389
+ all of them is provided as well.
390
+
391
+ ```python
392
+ from typing import Annotated
393
+
394
+ from xtr_dependency_injection import Target, as_service
395
+ from xtr_storage import StorageOperatorInterface
396
+
397
+
398
+ @as_service
399
+ class Avatars:
400
+ def __init__(
401
+ self,
402
+ storage: StorageOperatorInterface, # the default storage
403
+ uploads: Annotated[StorageOperatorInterface, Target("uploads")],
404
+ ) -> None: ...
405
+ ```
406
+
407
+ | `StorageConfig` field | Meaning |
408
+ |---|---|
409
+ | `storages` | Each storage by name: a full `StorageDefinition`, or a bare adapter configuration read as a definition with everything else at its default. An empty mapping means the `default` local storage |
410
+
411
+ A `StorageDefinition` names an adapter configuration — `LocalAdapterConfig`,
412
+ `MemoryAdapterConfig`, `S3AdapterConfig`, `GcsAdapterConfig`,
413
+ `FsspecAdapterConfig`, or a `Reference` to an adapter the container provides —
414
+ and layers the standing options over it: `visibility`, `directory_visibility`,
415
+ `retain_visibility`, `public_url`, `read_only` and `prefix`.
416
+
417
+ - **Zero config**: one `default` storage on local files under
418
+ `%kernel.project_dir%/var/storage`. Nothing is opened, and no directory is
419
+ created, until a storage is first used.
420
+ - **Checked at boot**: booting reads the configuration, `env()` values included,
421
+ and refuses a `Reference` the container does not provide, an adapter whose
422
+ backend extra is not installed, or a visibility set on a backend that has none
423
+ — naming the storage, never a credential.
424
+ - **Lifecycle**: when the container closes, every storage closes the connections
425
+ it opened.
426
+
427
+ ## Errors
428
+
429
+ Every error derives from `StorageError`, so one `except` catches anything
430
+ storing a file can go wrong with. The failures of an operation a backend refused
431
+ share `StorageOperationFailedError` and name that operation; everything else is a
432
+ mistake to fix rather than to retry. No message ever repeats a credential.
433
+
434
+ | Error | Raised when |
435
+ |---|---|
436
+ | `UnableToReadFileError` | A read fails or the file is missing |
437
+ | `UnableToWriteFileError` | A write fails |
438
+ | `UnableToDeleteFileError` / `UnableToDeleteDirectoryError` | A delete fails |
439
+ | `UnableToCreateDirectoryError` | A directory cannot be created |
440
+ | `UnableToSetVisibilityError` | A visibility cannot be set |
441
+ | `UnableToRetrieveMetadataError` | A size, modification time, mime type or visibility cannot be read |
442
+ | `UnableToCopyFileError` / `UnableToMoveFileError` | A copy or move fails, or source and destination are the same under a `fail` policy |
443
+ | `UnableToCheckExistenceError` (`…File…`, `…Directory…`) | An existence check fails |
444
+ | `UnableToListContentsError` | A listing fails while iterating |
445
+ | `UnableToProvideChecksumError` | A checksum cannot be produced |
446
+ | `ChecksumAlgorithmNotSupportedError` | The backend has no native checksum for the algorithm asked |
447
+ | `UnableToGeneratePublicUrlError` / `UnableToGenerateTemporaryUrlError` | No generator is configured, or the backend cannot produce the url |
448
+ | `UnableToResolveMountError` | A mount location names no storage, or is malformed |
449
+ | `PathTraversalDetectedError` / `CorruptedPathDetectedError` | A path climbs past the root, or carries a control character |
450
+ | `SymbolicLinkEncounteredError` | A listing meets a symbolic link a local storage was told to refuse |
451
+ | `FeatureNotSupportedError` | A backend is asked for a capability it does not have (also a `NotImplementedError`) |
452
+ | `MissingBackendError` | An adapter's extra package is not installed (also an `ImportError`) |
453
+ | `InvalidVisibilityError` | A string names no visibility (also a `ValueError`) |
454
+ | `InvalidStreamError` | A write stream is of a type the storage cannot read (also a `TypeError`) |
455
+ | `InvalidArgumentError` | An argument or option is invalid (also a `ValueError`) |
456
+
457
+ ## Development
458
+
459
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo,
460
+ under `packages/xtr-storage`; run the commands below from there. The
461
+ `python-xtr-storage` repository is a read-only copy, so send issues and pull
462
+ requests to the monorepo.
463
+
464
+ ```sh
465
+ uv sync
466
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
467
+ ```
468
+
469
+ The suite reaches no cloud: the S3 adapter runs against a local server kept in
470
+ the test process, the Google Cloud adapter against an in-test fake, and every
471
+ adapter that stores bytes passes the same conformance tests.
472
+
473
+ ## License
474
+
475
+ MIT — see [LICENSE](LICENSE).