asanypath 2.1.1__tar.gz → 2.3.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.
- {asanypath-2.1.1 → asanypath-2.3.0}/.github/workflows/unittests.yml +2 -5
- {asanypath-2.1.1 → asanypath-2.3.0}/.pre-commit-config.yaml +7 -1
- {asanypath-2.1.1 → asanypath-2.3.0}/PKG-INFO +178 -6
- {asanypath-2.1.1 → asanypath-2.3.0}/README.md +176 -4
- {asanypath-2.1.1 → asanypath-2.3.0}/pyproject.toml +17 -5
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/__init__.py +4 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/__init__.pyi +1 -0
- asanypath-2.3.0/src/asanypath/_backend_opts.py +61 -0
- asanypath-2.3.0/src/asanypath/_resume.py +43 -0
- asanypath-2.3.0/src/asanypath/_transfer.py +425 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/_version.py +2 -2
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/artifactory.py +57 -1
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/azure.py +59 -2
- asanypath-2.3.0/src/asanypath/cache.py +171 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/cli.py +133 -32
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/cloud.py +247 -41
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/ftp.py +44 -8
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/gcs.py +47 -2
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/http.py +53 -3
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/local.py +39 -24
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/options.py +30 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/s3.py +72 -1
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/ssh.py +49 -7
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_artifactory.py +37 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_azure.py +42 -0
- asanypath-2.3.0/tests/test_backend_options.py +109 -0
- asanypath-2.3.0/tests/test_cache.py +497 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_cli.py +135 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_cloudpathmixin.py +367 -4
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_ftp.py +38 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_gcs.py +69 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_http.py +93 -1
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_local.py +6 -0
- asanypath-2.3.0/tests/test_options.py +32 -0
- asanypath-2.3.0/tests/test_resume.py +198 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_s3.py +186 -1
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_ssh.py +54 -0
- asanypath-2.1.1/src/asanypath/_transfer.py +0 -224
- asanypath-2.1.1/uv.lock +0 -2831
- {asanypath-2.1.1 → asanypath-2.3.0}/.copilot/skills/async-python-lib/SKILL.md +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/.copilot/skills/gitguro/SKILL.md +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/.github/CODEOWNERS +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/.github/dependabot.yml +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/.github/workflows/publish.yml +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/.gitignore +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/CODE_OF_CONDUCT.md +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/CONTRIBUTING.md +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/LICENSE +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/SECURITY.md +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/asanypath.png +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/compose.yml +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/hatch_build.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/scripts/bench.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/_credstore.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/asanypath.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/asanypath.pyi +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/batcher.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/common.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/exceptions.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/py.typed +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/sync.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/unsupported.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/__init__.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/conftest.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_asanypath.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_batcher.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_commonpathmixin.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_credstore.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_http_auth.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_py_typed.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_sync.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_syncpath.py +0 -0
- {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_unsupported_path.py +0 -0
|
@@ -49,11 +49,8 @@ jobs:
|
|
|
49
49
|
- name: Ruff checks
|
|
50
50
|
run: uvx ruff check src tests && uvx ruff format --check src tests
|
|
51
51
|
|
|
52
|
-
- name:
|
|
53
|
-
run: uv
|
|
54
|
-
|
|
55
|
-
- name: Install project and native extension
|
|
56
|
-
run: uv pip install -e ".[dev]"
|
|
52
|
+
- name: Install project and native extension from uv.lock
|
|
53
|
+
run: uv sync --locked --extra dev --python "${{ matrix.python-version }}"
|
|
57
54
|
|
|
58
55
|
- name: Test with pytest and coverage
|
|
59
56
|
env:
|
|
@@ -22,4 +22,10 @@ repos:
|
|
|
22
22
|
entry: cargo check --manifest-path rust/Cargo.toml --locked
|
|
23
23
|
language: system
|
|
24
24
|
files: ^rust/.*\.(rs|toml|lock)$
|
|
25
|
-
pass_filenames: false
|
|
25
|
+
pass_filenames: false
|
|
26
|
+
# uv.lock must only reference the public index (a mirror URL leaks internal hosts).
|
|
27
|
+
- id: uv-lock-public-index
|
|
28
|
+
name: uv.lock uses only public index URLs
|
|
29
|
+
language: pygrep
|
|
30
|
+
entry: '(registry|url) = "https?://(?!pypi\.org/|files\.pythonhosted\.org/)'
|
|
31
|
+
files: ^uv\.lock$
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: asanypath
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Async pathlib for local and cloud storage
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Requires-Dist: anyio>=4
|
|
22
|
-
Requires-Dist: asanypath-native~=2.
|
|
22
|
+
Requires-Dist: asanypath-native~=2.3.0
|
|
23
23
|
Requires-Dist: msgspec>=0.22.0
|
|
24
24
|
Requires-Dist: yarl>=1.2
|
|
25
25
|
Provides-Extra: all
|
|
@@ -145,6 +145,46 @@ await src.copy(dst, recursive=True)
|
|
|
145
145
|
await path.rmdir(recursive=True)
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
Copies automatically use spare transfer concurrency for large ranged reads.
|
|
149
|
+
The default target is 8 streams, with an 8 MiB minimum range size and at most
|
|
150
|
+
8 range streams per file. Files smaller than 16 MiB stay single-stream. When
|
|
151
|
+
enough files are active to meet the target, automatic copies use one request
|
|
152
|
+
per file instead of adding ranges. The target is best-effort, not a minimum
|
|
153
|
+
number of requests that must always be launched.
|
|
154
|
+
|
|
155
|
+
S3, GCS, Azure, Artifactory, SSH, and range-capable HTTP/HTTPS sources use the
|
|
156
|
+
shared reader for local downloads and source reads in cross-backend copies.
|
|
157
|
+
FTP/FTPS, sources without size metadata, and HTTP servers without advertised
|
|
158
|
+
range support retain single-stream downloads. Local-only copies and native
|
|
159
|
+
server-side copy/rename operations retain their existing implementations.
|
|
160
|
+
Uploads remain single writes; this does not introduce multipart uploads.
|
|
161
|
+
|
|
162
|
+
Concurrent library copies share a default budget within the event loop. To
|
|
163
|
+
configure a batch explicitly:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
import asyncio
|
|
167
|
+
from asanypath import TransferBudget
|
|
168
|
+
|
|
169
|
+
with TransferBudget(floor=8, ceiling=32, min_range_size=8 * 1024 * 1024, per_file_cap=8):
|
|
170
|
+
await asyncio.gather(*(src.copy(dst) for src, dst in pairs))
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The ceiling bounds concurrent reads in this budget, not metadata requests or
|
|
174
|
+
other processes. Parallel range buffers are bounded by the active streams
|
|
175
|
+
times the range size; cross-backend destinations still buffer the complete
|
|
176
|
+
object for their existing write API. `floor=1` disables automatic splitting.
|
|
177
|
+
Explicit `chunk_size` requests retain sequential streaming when splitting
|
|
178
|
+
would be inappropriate, and `chunk_size=0` disables streaming to local paths.
|
|
179
|
+
|
|
180
|
+
S3/Azure ETags, GCS generations, and HTTP conditional requests pin the source
|
|
181
|
+
version when available. Other sources are checked against fresh metadata
|
|
182
|
+
after the download, which cannot guarantee a snapshot if the backend does
|
|
183
|
+
not expose a reliable change token. Use `atomic=True` to keep an existing
|
|
184
|
+
local destination intact on failure. Moves remove the source only after a
|
|
185
|
+
successful copy. The updated Python package requires the matching native
|
|
186
|
+
extension for conditional cloud reads.
|
|
187
|
+
|
|
148
188
|
For synchronous code (no `await` needed):
|
|
149
189
|
|
|
150
190
|
```python
|
|
@@ -161,11 +201,136 @@ for child in p.parent.iterdir(): # yields synchronously
|
|
|
161
201
|
Local paths (`file://` or bare `/path`) bypass the proxy entirely and use
|
|
162
202
|
`pathlib.Path` directly — zero threading overhead.
|
|
163
203
|
|
|
204
|
+
### Large uploads stream automatically
|
|
205
|
+
|
|
206
|
+
Writing or copying a large object never buffers the whole thing in memory — it
|
|
207
|
+
streams in bounded-size parts using each backend's native mechanism (S3
|
|
208
|
+
multipart, Azure block blobs, GCS resumable, SSH chunked writes, FTP data
|
|
209
|
+
connection, Artifactory streamed `PUT`). This is the **default** behaviour — no
|
|
210
|
+
flag, no API change:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
# open("wb") spills to disk past 16 MiB and uploads in parts on close
|
|
214
|
+
async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
|
|
215
|
+
f.write(data)
|
|
216
|
+
|
|
217
|
+
# local -> cloud copy streams straight from the source file (async and sync)
|
|
218
|
+
await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Peak memory per upload is one part — **8 MiB by default**. Objects smaller than
|
|
222
|
+
one part upload in a single request. (`write_bytes(data)` / `write_text(...)`
|
|
223
|
+
take the whole payload in memory by definition — use `open("wb")` or `copy()`
|
|
224
|
+
for large data.)
|
|
225
|
+
|
|
226
|
+
**Tuning the memory footprint.** The part size *is* the max RAM held per
|
|
227
|
+
in-flight chunk (parts upload sequentially, so there is no concurrency
|
|
228
|
+
multiplier). Set it globally via env, or per call:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
|
|
232
|
+
export ASANYPATH_SPOOL_MAX_SIZE=67108864 # 64 MiB before open("wb") spills to disk
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
# per call: copy(chunk_size=...) or, for writes, open(..., buffering=...)
|
|
237
|
+
await src.copy("s3://bucket/huge.bin", chunk_size=32 * 1024 * 1024)
|
|
238
|
+
async with dst.open("wb", buffering=32 * 1024 * 1024) as f:
|
|
239
|
+
f.write(data)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
|
|
243
|
+
10,000 parts, so 8 MiB parts cap an object at ~80 GB). Backend minimums are
|
|
244
|
+
applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
|
|
245
|
+
256 KiB multiple.
|
|
246
|
+
|
|
164
247
|
Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
|
|
165
248
|
Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
|
|
166
249
|
operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
|
|
167
250
|
`UnsupportedProtocolError` on use.
|
|
168
251
|
|
|
252
|
+
### Download caching (opt-in)
|
|
253
|
+
|
|
254
|
+
Reading the same object or URL twice always re-downloads it — there is **no
|
|
255
|
+
cache by default**, and the read path is unchanged unless you opt in, so
|
|
256
|
+
non-caching callers pay zero overhead.
|
|
257
|
+
|
|
258
|
+
Caching is included in the native extension with no extra dependencies or
|
|
259
|
+
installation extras. Opt in per read with `use_cache=True`:
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from asanypath import AsAnyPath
|
|
263
|
+
|
|
264
|
+
p = AsAnyPath("s3://bucket/model.bin")
|
|
265
|
+
data = await p.read_bytes(use_cache=True) # first call fetches + stores
|
|
266
|
+
data = await p.read_bytes(use_cache=True) # subsequent calls served from disk
|
|
267
|
+
text = await p.read_text(use_cache=True)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Configure the location and limits once (location defaults to
|
|
271
|
+
`~/.cache/asanypath`, honoring `$XDG_CACHE_HOME` and `$ASANYPATH_CACHE_DIR`):
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from asanypath import configure_cache
|
|
275
|
+
|
|
276
|
+
configure_cache(
|
|
277
|
+
directory="/var/cache/asanypath", # default: ~/.cache/asanypath
|
|
278
|
+
size_limit=2 * 1024**3, # stored key + payload budget (default 1 GiB, LRU)
|
|
279
|
+
ttl=3600, # per-entry expiry in seconds (default: none)
|
|
280
|
+
enabled=True, # make use_cache default to True process-wide
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Or via environment: `ASANYPATH_CACHE_DIR`, `ASANYPATH_CACHE_SIZE_LIMIT` (bytes),
|
|
285
|
+
`ASANYPATH_CACHE_TTL` (seconds).
|
|
286
|
+
|
|
287
|
+
Behaviour and trade-offs:
|
|
288
|
+
|
|
289
|
+
- **Off by default, opt-in per call.** With `configure_cache(enabled=True)` the
|
|
290
|
+
default flips process-wide; an explicit `use_cache=False` always bypasses.
|
|
291
|
+
- **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
|
|
292
|
+
FTP). Local paths are never cached.
|
|
293
|
+
- **Size-bounded, LRU-evicted;** disk I/O runs in a worker thread so the
|
|
294
|
+
event loop stays responsive. Concurrent reads of the same key collapse to a
|
|
295
|
+
single fetch within a cache instance. The byte budget counts UTF-8 keys and
|
|
296
|
+
payloads; SQLite metadata and transaction journals add filesystem overhead.
|
|
297
|
+
Objects larger than the budget are returned without being cached.
|
|
298
|
+
- **Byte-only native storage:** SQLite stores raw bytes, never Python pickles.
|
|
299
|
+
The database lives under `native-v1/` in the configured cache directory.
|
|
300
|
+
Legacy DiskCache files are ignored, not migrated or deleted. Multiple
|
|
301
|
+
processes can share the new store; use the same size limit across them.
|
|
302
|
+
- **Staleness is on you.** Entries are keyed by URL with no revalidation — a
|
|
303
|
+
cached object that changes upstream is served stale until it expires (`ttl`)
|
|
304
|
+
or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
|
|
305
|
+
|
|
306
|
+
### Resumable downloads
|
|
307
|
+
|
|
308
|
+
`download(dst)` streams an object to a local file and **resumes an interrupted
|
|
309
|
+
transfer** instead of starting over:
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
|
|
313
|
+
# process killed mid-download... run the same call again — it continues
|
|
314
|
+
await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Bytes stream into `<dst>.part` with a `<dst>.part.meta` sidecar recording the
|
|
318
|
+
object's ETag (or `Last-Modified`) and total size. On resume the identity is
|
|
319
|
+
re-checked: if the upstream object is unchanged the download continues from the
|
|
320
|
+
saved offset; if it changed, the partial is discarded and it restarts cleanly.
|
|
321
|
+
On completion `<dst>.part` is atomically renamed to `dst`.
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
# tune the range-request size; disable resume to always start fresh
|
|
325
|
+
await path.download("/data/huge.bin", chunk_size=32 * 1024 * 1024, resume=False)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Resume needs range reads **and** a HEAD probe, so it's available on the object
|
|
329
|
+
stores (S3/GCS/Azure) and Artifactory/HTTP where the server supports it.
|
|
330
|
+
Backends without those (e.g. FTP) download in a single pass automatically. This
|
|
331
|
+
needs no extra dependency — resume state is plain sidecar files, independent of
|
|
332
|
+
the optional download cache.
|
|
333
|
+
|
|
169
334
|
## Installation
|
|
170
335
|
|
|
171
336
|
Most cloud backends (S3, GCS, Azure, Artifactory, HTTP) are included in the base
|
|
@@ -232,6 +397,12 @@ asanypath cp /local/file.txt s3://bucket/file.txt
|
|
|
232
397
|
# Copy cloud → local
|
|
233
398
|
asanypath cp s3://bucket/file.txt /local/file.txt
|
|
234
399
|
|
|
400
|
+
# Allow 32 concurrent files; fill spare capacity toward 8 streams for large files
|
|
401
|
+
asanypath cp -r s3://bucket/data/ ./data/ -j 32 --concurrency-floor 8
|
|
402
|
+
|
|
403
|
+
# Disable automatic range splitting (also supported by mv and sync)
|
|
404
|
+
asanypath cp s3://bucket/file.txt ./file.txt --concurrency-floor 1
|
|
405
|
+
|
|
235
406
|
# Delete a file
|
|
236
407
|
asanypath rm s3://bucket/file.txt --force
|
|
237
408
|
|
|
@@ -438,10 +609,11 @@ Factory that returns the appropriate path instance based on the URI scheme.
|
|
|
438
609
|
|
|
439
610
|
All path implementations support:
|
|
440
611
|
|
|
441
|
-
- `open(mode, buffering)` — file-like streaming with lazy range reads
|
|
442
|
-
- `copy(dst, recursive)` — copy file or tree (cross-backend)
|
|
443
|
-
- `
|
|
444
|
-
- `
|
|
612
|
+
- `open(mode, buffering)` — file-like streaming with lazy range reads; for writes, `buffering` sets the upload part size
|
|
613
|
+
- `copy(dst, recursive, chunk_size)` — copy file or tree (cross-backend); `chunk_size` tunes the upload part size
|
|
614
|
+
- `download(dst, resume, chunk_size)` — stream an object to a local file, resuming an interrupted transfer
|
|
615
|
+
- `read_bytes()` / `write_bytes(data)` — `read_bytes(use_cache=True)` serves from / stores in the opt-in [download cache](#download-caching-opt-in)
|
|
616
|
+
- `read_text(encoding)` / `write_text(data, encoding)` — `read_text` also accepts `use_cache`
|
|
445
617
|
- `exists()` / `is_file()` / `is_dir()`
|
|
446
618
|
- `stat()` / `checksums()` (cloud backends)
|
|
447
619
|
- `mkdir(parents, exist_ok)` / `touch(exist_ok)`
|
|
@@ -90,6 +90,46 @@ await src.copy(dst, recursive=True)
|
|
|
90
90
|
await path.rmdir(recursive=True)
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
Copies automatically use spare transfer concurrency for large ranged reads.
|
|
94
|
+
The default target is 8 streams, with an 8 MiB minimum range size and at most
|
|
95
|
+
8 range streams per file. Files smaller than 16 MiB stay single-stream. When
|
|
96
|
+
enough files are active to meet the target, automatic copies use one request
|
|
97
|
+
per file instead of adding ranges. The target is best-effort, not a minimum
|
|
98
|
+
number of requests that must always be launched.
|
|
99
|
+
|
|
100
|
+
S3, GCS, Azure, Artifactory, SSH, and range-capable HTTP/HTTPS sources use the
|
|
101
|
+
shared reader for local downloads and source reads in cross-backend copies.
|
|
102
|
+
FTP/FTPS, sources without size metadata, and HTTP servers without advertised
|
|
103
|
+
range support retain single-stream downloads. Local-only copies and native
|
|
104
|
+
server-side copy/rename operations retain their existing implementations.
|
|
105
|
+
Uploads remain single writes; this does not introduce multipart uploads.
|
|
106
|
+
|
|
107
|
+
Concurrent library copies share a default budget within the event loop. To
|
|
108
|
+
configure a batch explicitly:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
import asyncio
|
|
112
|
+
from asanypath import TransferBudget
|
|
113
|
+
|
|
114
|
+
with TransferBudget(floor=8, ceiling=32, min_range_size=8 * 1024 * 1024, per_file_cap=8):
|
|
115
|
+
await asyncio.gather(*(src.copy(dst) for src, dst in pairs))
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The ceiling bounds concurrent reads in this budget, not metadata requests or
|
|
119
|
+
other processes. Parallel range buffers are bounded by the active streams
|
|
120
|
+
times the range size; cross-backend destinations still buffer the complete
|
|
121
|
+
object for their existing write API. `floor=1` disables automatic splitting.
|
|
122
|
+
Explicit `chunk_size` requests retain sequential streaming when splitting
|
|
123
|
+
would be inappropriate, and `chunk_size=0` disables streaming to local paths.
|
|
124
|
+
|
|
125
|
+
S3/Azure ETags, GCS generations, and HTTP conditional requests pin the source
|
|
126
|
+
version when available. Other sources are checked against fresh metadata
|
|
127
|
+
after the download, which cannot guarantee a snapshot if the backend does
|
|
128
|
+
not expose a reliable change token. Use `atomic=True` to keep an existing
|
|
129
|
+
local destination intact on failure. Moves remove the source only after a
|
|
130
|
+
successful copy. The updated Python package requires the matching native
|
|
131
|
+
extension for conditional cloud reads.
|
|
132
|
+
|
|
93
133
|
For synchronous code (no `await` needed):
|
|
94
134
|
|
|
95
135
|
```python
|
|
@@ -106,11 +146,136 @@ for child in p.parent.iterdir(): # yields synchronously
|
|
|
106
146
|
Local paths (`file://` or bare `/path`) bypass the proxy entirely and use
|
|
107
147
|
`pathlib.Path` directly — zero threading overhead.
|
|
108
148
|
|
|
149
|
+
### Large uploads stream automatically
|
|
150
|
+
|
|
151
|
+
Writing or copying a large object never buffers the whole thing in memory — it
|
|
152
|
+
streams in bounded-size parts using each backend's native mechanism (S3
|
|
153
|
+
multipart, Azure block blobs, GCS resumable, SSH chunked writes, FTP data
|
|
154
|
+
connection, Artifactory streamed `PUT`). This is the **default** behaviour — no
|
|
155
|
+
flag, no API change:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
# open("wb") spills to disk past 16 MiB and uploads in parts on close
|
|
159
|
+
async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
|
|
160
|
+
f.write(data)
|
|
161
|
+
|
|
162
|
+
# local -> cloud copy streams straight from the source file (async and sync)
|
|
163
|
+
await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Peak memory per upload is one part — **8 MiB by default**. Objects smaller than
|
|
167
|
+
one part upload in a single request. (`write_bytes(data)` / `write_text(...)`
|
|
168
|
+
take the whole payload in memory by definition — use `open("wb")` or `copy()`
|
|
169
|
+
for large data.)
|
|
170
|
+
|
|
171
|
+
**Tuning the memory footprint.** The part size *is* the max RAM held per
|
|
172
|
+
in-flight chunk (parts upload sequentially, so there is no concurrency
|
|
173
|
+
multiplier). Set it globally via env, or per call:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
|
|
177
|
+
export ASANYPATH_SPOOL_MAX_SIZE=67108864 # 64 MiB before open("wb") spills to disk
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
# per call: copy(chunk_size=...) or, for writes, open(..., buffering=...)
|
|
182
|
+
await src.copy("s3://bucket/huge.bin", chunk_size=32 * 1024 * 1024)
|
|
183
|
+
async with dst.open("wb", buffering=32 * 1024 * 1024) as f:
|
|
184
|
+
f.write(data)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
|
|
188
|
+
10,000 parts, so 8 MiB parts cap an object at ~80 GB). Backend minimums are
|
|
189
|
+
applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
|
|
190
|
+
256 KiB multiple.
|
|
191
|
+
|
|
109
192
|
Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
|
|
110
193
|
Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
|
|
111
194
|
operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
|
|
112
195
|
`UnsupportedProtocolError` on use.
|
|
113
196
|
|
|
197
|
+
### Download caching (opt-in)
|
|
198
|
+
|
|
199
|
+
Reading the same object or URL twice always re-downloads it — there is **no
|
|
200
|
+
cache by default**, and the read path is unchanged unless you opt in, so
|
|
201
|
+
non-caching callers pay zero overhead.
|
|
202
|
+
|
|
203
|
+
Caching is included in the native extension with no extra dependencies or
|
|
204
|
+
installation extras. Opt in per read with `use_cache=True`:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from asanypath import AsAnyPath
|
|
208
|
+
|
|
209
|
+
p = AsAnyPath("s3://bucket/model.bin")
|
|
210
|
+
data = await p.read_bytes(use_cache=True) # first call fetches + stores
|
|
211
|
+
data = await p.read_bytes(use_cache=True) # subsequent calls served from disk
|
|
212
|
+
text = await p.read_text(use_cache=True)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Configure the location and limits once (location defaults to
|
|
216
|
+
`~/.cache/asanypath`, honoring `$XDG_CACHE_HOME` and `$ASANYPATH_CACHE_DIR`):
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from asanypath import configure_cache
|
|
220
|
+
|
|
221
|
+
configure_cache(
|
|
222
|
+
directory="/var/cache/asanypath", # default: ~/.cache/asanypath
|
|
223
|
+
size_limit=2 * 1024**3, # stored key + payload budget (default 1 GiB, LRU)
|
|
224
|
+
ttl=3600, # per-entry expiry in seconds (default: none)
|
|
225
|
+
enabled=True, # make use_cache default to True process-wide
|
|
226
|
+
)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Or via environment: `ASANYPATH_CACHE_DIR`, `ASANYPATH_CACHE_SIZE_LIMIT` (bytes),
|
|
230
|
+
`ASANYPATH_CACHE_TTL` (seconds).
|
|
231
|
+
|
|
232
|
+
Behaviour and trade-offs:
|
|
233
|
+
|
|
234
|
+
- **Off by default, opt-in per call.** With `configure_cache(enabled=True)` the
|
|
235
|
+
default flips process-wide; an explicit `use_cache=False` always bypasses.
|
|
236
|
+
- **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
|
|
237
|
+
FTP). Local paths are never cached.
|
|
238
|
+
- **Size-bounded, LRU-evicted;** disk I/O runs in a worker thread so the
|
|
239
|
+
event loop stays responsive. Concurrent reads of the same key collapse to a
|
|
240
|
+
single fetch within a cache instance. The byte budget counts UTF-8 keys and
|
|
241
|
+
payloads; SQLite metadata and transaction journals add filesystem overhead.
|
|
242
|
+
Objects larger than the budget are returned without being cached.
|
|
243
|
+
- **Byte-only native storage:** SQLite stores raw bytes, never Python pickles.
|
|
244
|
+
The database lives under `native-v1/` in the configured cache directory.
|
|
245
|
+
Legacy DiskCache files are ignored, not migrated or deleted. Multiple
|
|
246
|
+
processes can share the new store; use the same size limit across them.
|
|
247
|
+
- **Staleness is on you.** Entries are keyed by URL with no revalidation — a
|
|
248
|
+
cached object that changes upstream is served stale until it expires (`ttl`)
|
|
249
|
+
or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
|
|
250
|
+
|
|
251
|
+
### Resumable downloads
|
|
252
|
+
|
|
253
|
+
`download(dst)` streams an object to a local file and **resumes an interrupted
|
|
254
|
+
transfer** instead of starting over:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
|
|
258
|
+
# process killed mid-download... run the same call again — it continues
|
|
259
|
+
await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Bytes stream into `<dst>.part` with a `<dst>.part.meta` sidecar recording the
|
|
263
|
+
object's ETag (or `Last-Modified`) and total size. On resume the identity is
|
|
264
|
+
re-checked: if the upstream object is unchanged the download continues from the
|
|
265
|
+
saved offset; if it changed, the partial is discarded and it restarts cleanly.
|
|
266
|
+
On completion `<dst>.part` is atomically renamed to `dst`.
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
# tune the range-request size; disable resume to always start fresh
|
|
270
|
+
await path.download("/data/huge.bin", chunk_size=32 * 1024 * 1024, resume=False)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Resume needs range reads **and** a HEAD probe, so it's available on the object
|
|
274
|
+
stores (S3/GCS/Azure) and Artifactory/HTTP where the server supports it.
|
|
275
|
+
Backends without those (e.g. FTP) download in a single pass automatically. This
|
|
276
|
+
needs no extra dependency — resume state is plain sidecar files, independent of
|
|
277
|
+
the optional download cache.
|
|
278
|
+
|
|
114
279
|
## Installation
|
|
115
280
|
|
|
116
281
|
Most cloud backends (S3, GCS, Azure, Artifactory, HTTP) are included in the base
|
|
@@ -177,6 +342,12 @@ asanypath cp /local/file.txt s3://bucket/file.txt
|
|
|
177
342
|
# Copy cloud → local
|
|
178
343
|
asanypath cp s3://bucket/file.txt /local/file.txt
|
|
179
344
|
|
|
345
|
+
# Allow 32 concurrent files; fill spare capacity toward 8 streams for large files
|
|
346
|
+
asanypath cp -r s3://bucket/data/ ./data/ -j 32 --concurrency-floor 8
|
|
347
|
+
|
|
348
|
+
# Disable automatic range splitting (also supported by mv and sync)
|
|
349
|
+
asanypath cp s3://bucket/file.txt ./file.txt --concurrency-floor 1
|
|
350
|
+
|
|
180
351
|
# Delete a file
|
|
181
352
|
asanypath rm s3://bucket/file.txt --force
|
|
182
353
|
|
|
@@ -383,10 +554,11 @@ Factory that returns the appropriate path instance based on the URI scheme.
|
|
|
383
554
|
|
|
384
555
|
All path implementations support:
|
|
385
556
|
|
|
386
|
-
- `open(mode, buffering)` — file-like streaming with lazy range reads
|
|
387
|
-
- `copy(dst, recursive)` — copy file or tree (cross-backend)
|
|
388
|
-
- `
|
|
389
|
-
- `
|
|
557
|
+
- `open(mode, buffering)` — file-like streaming with lazy range reads; for writes, `buffering` sets the upload part size
|
|
558
|
+
- `copy(dst, recursive, chunk_size)` — copy file or tree (cross-backend); `chunk_size` tunes the upload part size
|
|
559
|
+
- `download(dst, resume, chunk_size)` — stream an object to a local file, resuming an interrupted transfer
|
|
560
|
+
- `read_bytes()` / `write_bytes(data)` — `read_bytes(use_cache=True)` serves from / stores in the opt-in [download cache](#download-caching-opt-in)
|
|
561
|
+
- `read_text(encoding)` / `write_text(data, encoding)` — `read_text` also accepts `use_cache`
|
|
390
562
|
- `exists()` / `is_file()` / `is_dir()`
|
|
391
563
|
- `stat()` / `checksums()` (cloud backends)
|
|
392
564
|
- `mkdir(parents, exist_ok)` / `touch(exist_ok)`
|
|
@@ -20,7 +20,7 @@ asanypath-native = { path = "rust" }
|
|
|
20
20
|
root = "src"
|
|
21
21
|
|
|
22
22
|
[tool.hatch.build.targets.sdist]
|
|
23
|
-
exclude = ["rust"]
|
|
23
|
+
exclude = ["rust", "uv.lock"]
|
|
24
24
|
|
|
25
25
|
[tool.hatch.metadata.hooks.custom]
|
|
26
26
|
|
|
@@ -102,7 +102,7 @@ disable_error_code = ["attr-defined", "arg-type", "assignment"]
|
|
|
102
102
|
module = "asanypath.local"
|
|
103
103
|
disallow_untyped_defs = false
|
|
104
104
|
warn_return_any = false
|
|
105
|
-
disable_error_code = ["type-var", "misc", "override", "return-value"]
|
|
105
|
+
disable_error_code = ["type-var", "misc", "override", "return-value", "assignment", "no-redef"]
|
|
106
106
|
|
|
107
107
|
[[tool.mypy.overrides]]
|
|
108
108
|
module = "asanypath.common"
|
|
@@ -120,19 +120,31 @@ disable_error_code = ["override", "type-arg", "return-value", "assignment", "att
|
|
|
120
120
|
module = ["asanypath.s3", "asanypath.gcs", "asanypath.azure", "asanypath.artifactory"]
|
|
121
121
|
disallow_untyped_defs = false
|
|
122
122
|
warn_return_any = false
|
|
123
|
-
disable_error_code = ["no-any-return", "attr-defined", "assignment", "override"]
|
|
123
|
+
disable_error_code = ["no-any-return", "attr-defined", "assignment", "override", "arg-type", "misc"]
|
|
124
|
+
|
|
125
|
+
[[tool.mypy.overrides]]
|
|
126
|
+
module = ["asanypath.ssh", "asanypath.ftp"]
|
|
127
|
+
disallow_untyped_defs = false
|
|
128
|
+
warn_return_any = false
|
|
129
|
+
disable_error_code = ["attr-defined", "override", "arg-type", "misc", "no-any-return", "assignment"]
|
|
130
|
+
|
|
131
|
+
[[tool.mypy.overrides]]
|
|
132
|
+
module = "asanypath.sync"
|
|
133
|
+
disallow_untyped_defs = false
|
|
134
|
+
warn_return_any = false
|
|
135
|
+
disable_error_code = ["no-any-return", "misc", "attr-defined", "arg-type"]
|
|
124
136
|
|
|
125
137
|
[[tool.mypy.overrides]]
|
|
126
138
|
module = "asanypath.cloud"
|
|
127
139
|
disallow_untyped_defs = false
|
|
128
140
|
warn_return_any = false
|
|
129
|
-
disable_error_code = ["no-any-return", "arg-type"]
|
|
141
|
+
disable_error_code = ["no-any-return", "arg-type", "attr-defined", "misc", "assignment"]
|
|
130
142
|
|
|
131
143
|
[[tool.mypy.overrides]]
|
|
132
144
|
module = "asanypath.asanypath"
|
|
133
145
|
disallow_untyped_defs = false
|
|
134
146
|
warn_return_any = false
|
|
135
|
-
disable_error_code = ["no-any-return", "misc", "union-attr", "type-var"]
|
|
147
|
+
disable_error_code = ["no-any-return", "misc", "union-attr", "type-var", "attr-defined"]
|
|
136
148
|
|
|
137
149
|
[[tool.mypy.overrides]]
|
|
138
150
|
module = "asanypath.batcher"
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
# license that can be found in the LICENSE file or at
|
|
5
5
|
# https://opensource.org/licenses/MIT.
|
|
6
6
|
|
|
7
|
+
from asanypath._transfer import TransferBudget
|
|
7
8
|
from asanypath.artifactory import ArtifactoryPath
|
|
8
9
|
from asanypath.asanypath import (
|
|
9
10
|
PROTOCOL_MAP,
|
|
@@ -13,6 +14,7 @@ from asanypath.asanypath import (
|
|
|
13
14
|
register_protocol,
|
|
14
15
|
)
|
|
15
16
|
from asanypath.azure import AzurePath
|
|
17
|
+
from asanypath.cache import configure_cache
|
|
16
18
|
from asanypath.cloud import CloudPathMixin
|
|
17
19
|
from asanypath.common import CommonPurePathMixin
|
|
18
20
|
from asanypath.exceptions import (
|
|
@@ -52,6 +54,7 @@ __all__ = [
|
|
|
52
54
|
"AsAnyPath",
|
|
53
55
|
"AsyncPath",
|
|
54
56
|
"SyncPath",
|
|
57
|
+
"TransferBudget",
|
|
55
58
|
"BackendOptions",
|
|
56
59
|
"AccessGrant",
|
|
57
60
|
"AccessPolicy",
|
|
@@ -62,6 +65,7 @@ __all__ = [
|
|
|
62
65
|
"GCSPath",
|
|
63
66
|
"AzurePath",
|
|
64
67
|
"ArtifactoryPath",
|
|
68
|
+
"configure_cache",
|
|
65
69
|
"register_protocol",
|
|
66
70
|
"list_protocols",
|
|
67
71
|
"AsAnyPathError",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""CLI backend options: ``scheme.key=value`` mapped onto path constructor kwargs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from asanypath.asanypath import PROTOCOL_MAP, _match_base_url_flavor
|
|
11
|
+
|
|
12
|
+
Options = dict[str, dict[str, Any]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def option_keys(cls: type) -> dict[str, Callable[[str], Any]]:
|
|
16
|
+
"""Return ``{key: coercer}`` for the keyword arguments a path class accepts."""
|
|
17
|
+
keys: dict[str, Callable[[str], Any]] = {}
|
|
18
|
+
for name, param in inspect.signature(cls.__init__).parameters.items():
|
|
19
|
+
if name == "self" or param.kind in (
|
|
20
|
+
param.VAR_POSITIONAL,
|
|
21
|
+
param.VAR_KEYWORD,
|
|
22
|
+
param.POSITIONAL_ONLY,
|
|
23
|
+
):
|
|
24
|
+
continue
|
|
25
|
+
ann = str(param.annotation)
|
|
26
|
+
keys[name] = int if re.search(r"\bint\b", ann) and "str" not in ann else str
|
|
27
|
+
return keys
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_options(specs: Iterable[str]) -> Options:
|
|
31
|
+
"""Parse ``scheme.key=value`` specs; raise ValueError on anything unsupported."""
|
|
32
|
+
parsed: Options = {}
|
|
33
|
+
for spec in specs:
|
|
34
|
+
lhs, eq, value = spec.partition("=")
|
|
35
|
+
scheme, dot, key = lhs.partition(".")
|
|
36
|
+
if not eq or not dot or not key:
|
|
37
|
+
raise ValueError(f"invalid option {spec!r}; expected SCHEME.KEY=VALUE")
|
|
38
|
+
scheme = scheme.lower()
|
|
39
|
+
if scheme not in PROTOCOL_MAP:
|
|
40
|
+
raise ValueError(f"unknown protocol {scheme!r} in option {spec!r}")
|
|
41
|
+
keys = option_keys(PROTOCOL_MAP[scheme])
|
|
42
|
+
if key not in keys:
|
|
43
|
+
valid = ", ".join(sorted(keys)) or "none"
|
|
44
|
+
raise ValueError(f"{scheme} does not support option {key!r} (valid: {valid})")
|
|
45
|
+
try:
|
|
46
|
+
parsed.setdefault(scheme, {})[key] = keys[key](value)
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise ValueError(f"invalid value {value!r} for {scheme}.{key}") from None
|
|
49
|
+
return parsed
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def kwargs_for(path: str, options: Options) -> dict[str, Any]:
|
|
53
|
+
"""Return the options that apply to the backend serving ``path``."""
|
|
54
|
+
if not options:
|
|
55
|
+
return {}
|
|
56
|
+
flavor = _match_base_url_flavor(path)
|
|
57
|
+
merged: dict[str, Any] = {}
|
|
58
|
+
for scheme, values in options.items():
|
|
59
|
+
if PROTOCOL_MAP[scheme] is flavor:
|
|
60
|
+
merged.update(values)
|
|
61
|
+
return merged
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Copyright (C) 2026 Axis Communications AB, Lund, Sweden
|
|
2
|
+
#
|
|
3
|
+
# Use of this source code is governed by an MIT-style
|
|
4
|
+
# license that can be found in the LICENSE file or at
|
|
5
|
+
# https://opensource.org/licenses/MIT.
|
|
6
|
+
|
|
7
|
+
"""Sidecar state for resumable downloads.
|
|
8
|
+
|
|
9
|
+
A partial download lives in ``<dst>.part`` with a ``<dst>.part.meta`` sidecar
|
|
10
|
+
recording the remote object's identity (ETag/Last-Modified) and total size. On
|
|
11
|
+
resume the identity is re-checked so a changed upstream object restarts cleanly
|
|
12
|
+
rather than stitching mismatched bytes. State is plain files — no dependency on
|
|
13
|
+
the optional download cache.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import msgspec
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResumeMeta(msgspec.Struct, frozen=True):
|
|
24
|
+
"""Identity and size of the object a ``.part`` file is downloading."""
|
|
25
|
+
|
|
26
|
+
identity: str | None
|
|
27
|
+
size: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_encoder = msgspec.json.Encoder()
|
|
31
|
+
_decoder = msgspec.json.Decoder(ResumeMeta)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_meta(path: Path) -> ResumeMeta | None:
|
|
35
|
+
"""Return the sidecar meta at *path*, or ``None`` if missing/corrupt."""
|
|
36
|
+
try:
|
|
37
|
+
return _decoder.decode(path.read_bytes())
|
|
38
|
+
except (OSError, msgspec.DecodeError):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_meta(path: Path, meta: ResumeMeta) -> None:
|
|
43
|
+
path.write_bytes(_encoder.encode(meta))
|