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.
- xtr_storage-3.0.0/LICENSE +21 -0
- xtr_storage-3.0.0/PKG-INFO +475 -0
- xtr_storage-3.0.0/README.md +446 -0
- xtr_storage-3.0.0/pyproject.toml +173 -0
- xtr_storage-3.0.0/pyproject.toml.orig +180 -0
- xtr_storage-3.0.0/src/xtr_storage/.agents/skills/xtr-storage/SKILL.md +207 -0
- xtr_storage-3.0.0/src/xtr_storage/__init__.py +158 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/__init__.py +29 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/_fsspec_bridge.py +212 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/_object_store.py +108 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/fsspec_adapter.py +528 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/gcs_adapter.py +325 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/generic_fsspec_adapter.py +60 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/in_memory_adapter.py +174 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/local_adapter.py +259 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/path_prefixed_adapter.py +354 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/portable_visibility_converter.py +98 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/read_only_adapter.py +231 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/s3_adapter.py +441 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/storage_adapter_interface.py +217 -0
- xtr_storage-3.0.0/src/xtr_storage/adapter/visibility_converter_interface.py +88 -0
- xtr_storage-3.0.0/src/xtr_storage/bundle/__init__.py +28 -0
- xtr_storage-3.0.0/src/xtr_storage/bundle/adapter_configs.py +227 -0
- xtr_storage-3.0.0/src/xtr_storage/bundle/storage_bundle.py +328 -0
- xtr_storage-3.0.0/src/xtr_storage/bundle/storage_config.py +103 -0
- xtr_storage-3.0.0/src/xtr_storage/bundle/storage_definition.py +85 -0
- xtr_storage-3.0.0/src/xtr_storage/checksum_provider_interface.py +42 -0
- xtr_storage-3.0.0/src/xtr_storage/config.py +260 -0
- xtr_storage-3.0.0/src/xtr_storage/directory_attributes.py +80 -0
- xtr_storage-3.0.0/src/xtr_storage/directory_listing.py +149 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/__init__.py +75 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/checksum_algorithm_not_supported_error.py +29 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/corrupted_path_detected_error.py +27 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/feature_not_supported_error.py +39 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/invalid_argument_error.py +29 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/invalid_stream_error.py +30 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/invalid_visibility_error.py +30 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/missing_backend_error.py +34 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/path_traversal_detected_error.py +27 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/storage_error.py +19 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/storage_operation_failed_error.py +32 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/symbolic_link_encountered_error.py +26 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_check_directory_existence_error.py +24 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_check_existence_error.py +32 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_check_file_existence_error.py +24 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_copy_file_error.py +39 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_create_directory_error.py +32 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_delete_directory_error.py +36 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_delete_file_error.py +35 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_generate_public_url_error.py +30 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_generate_temporary_url_error.py +30 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_list_contents_error.py +37 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_move_file_error.py +39 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_provide_checksum_error.py +30 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_read_file_error.py +36 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_resolve_mount_error.py +30 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_retrieve_metadata_error.py +61 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_set_visibility_error.py +36 -0
- xtr_storage-3.0.0/src/xtr_storage/exception/unable_to_write_file_error.py +35 -0
- xtr_storage-3.0.0/src/xtr_storage/feature.py +23 -0
- xtr_storage-3.0.0/src/xtr_storage/file_attributes.py +88 -0
- xtr_storage-3.0.0/src/xtr_storage/identical_path_policy.py +27 -0
- xtr_storage-3.0.0/src/xtr_storage/link_handling.py +25 -0
- xtr_storage-3.0.0/src/xtr_storage/mime_type/__init__.py +8 -0
- xtr_storage-3.0.0/src/xtr_storage/mime_type/extension_mime_type_detector.py +53 -0
- xtr_storage-3.0.0/src/xtr_storage/mime_type/mime_type_detector_interface.py +32 -0
- xtr_storage-3.0.0/src/xtr_storage/mount_manager.py +461 -0
- xtr_storage-3.0.0/src/xtr_storage/operation.py +31 -0
- xtr_storage-3.0.0/src/xtr_storage/path/__init__.py +9 -0
- xtr_storage-3.0.0/src/xtr_storage/path/path_normalizer_interface.py +33 -0
- xtr_storage-3.0.0/src/xtr_storage/path/path_prefixer.py +66 -0
- xtr_storage-3.0.0/src/xtr_storage/path/whitespace_path_normalizer.py +68 -0
- xtr_storage-3.0.0/src/xtr_storage/py.typed +0 -0
- xtr_storage-3.0.0/src/xtr_storage/recipe/__init__.py +15 -0
- xtr_storage-3.0.0/src/xtr_storage/recipe/manifest.toml +21 -0
- xtr_storage-3.0.0/src/xtr_storage/storage.py +480 -0
- xtr_storage-3.0.0/src/xtr_storage/storage_attributes.py +18 -0
- xtr_storage-3.0.0/src/xtr_storage/storage_operator_interface.py +91 -0
- xtr_storage-3.0.0/src/xtr_storage/storage_reader_interface.py +189 -0
- xtr_storage-3.0.0/src/xtr_storage/storage_writer_interface.py +172 -0
- xtr_storage-3.0.0/src/xtr_storage/url_generation/__init__.py +17 -0
- xtr_storage-3.0.0/src/xtr_storage/url_generation/chained_public_url_generator.py +66 -0
- xtr_storage-3.0.0/src/xtr_storage/url_generation/prefix_public_url_generator.py +54 -0
- xtr_storage-3.0.0/src/xtr_storage/url_generation/public_url_generator_interface.py +41 -0
- xtr_storage-3.0.0/src/xtr_storage/url_generation/sharded_prefix_public_url_generator.py +68 -0
- xtr_storage-3.0.0/src/xtr_storage/url_generation/temporary_url_generator_interface.py +45 -0
- 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).
|