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.
Files changed (76) hide show
  1. {asanypath-2.1.1 → asanypath-2.3.0}/.github/workflows/unittests.yml +2 -5
  2. {asanypath-2.1.1 → asanypath-2.3.0}/.pre-commit-config.yaml +7 -1
  3. {asanypath-2.1.1 → asanypath-2.3.0}/PKG-INFO +178 -6
  4. {asanypath-2.1.1 → asanypath-2.3.0}/README.md +176 -4
  5. {asanypath-2.1.1 → asanypath-2.3.0}/pyproject.toml +17 -5
  6. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/__init__.py +4 -0
  7. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/__init__.pyi +1 -0
  8. asanypath-2.3.0/src/asanypath/_backend_opts.py +61 -0
  9. asanypath-2.3.0/src/asanypath/_resume.py +43 -0
  10. asanypath-2.3.0/src/asanypath/_transfer.py +425 -0
  11. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/_version.py +2 -2
  12. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/artifactory.py +57 -1
  13. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/azure.py +59 -2
  14. asanypath-2.3.0/src/asanypath/cache.py +171 -0
  15. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/cli.py +133 -32
  16. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/cloud.py +247 -41
  17. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/ftp.py +44 -8
  18. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/gcs.py +47 -2
  19. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/http.py +53 -3
  20. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/local.py +39 -24
  21. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/options.py +30 -0
  22. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/s3.py +72 -1
  23. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/ssh.py +49 -7
  24. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_artifactory.py +37 -0
  25. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_azure.py +42 -0
  26. asanypath-2.3.0/tests/test_backend_options.py +109 -0
  27. asanypath-2.3.0/tests/test_cache.py +497 -0
  28. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_cli.py +135 -0
  29. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_cloudpathmixin.py +367 -4
  30. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_ftp.py +38 -0
  31. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_gcs.py +69 -0
  32. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_http.py +93 -1
  33. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_local.py +6 -0
  34. asanypath-2.3.0/tests/test_options.py +32 -0
  35. asanypath-2.3.0/tests/test_resume.py +198 -0
  36. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_s3.py +186 -1
  37. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_ssh.py +54 -0
  38. asanypath-2.1.1/src/asanypath/_transfer.py +0 -224
  39. asanypath-2.1.1/uv.lock +0 -2831
  40. {asanypath-2.1.1 → asanypath-2.3.0}/.copilot/skills/async-python-lib/SKILL.md +0 -0
  41. {asanypath-2.1.1 → asanypath-2.3.0}/.copilot/skills/gitguro/SKILL.md +0 -0
  42. {asanypath-2.1.1 → asanypath-2.3.0}/.github/CODEOWNERS +0 -0
  43. {asanypath-2.1.1 → asanypath-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  44. {asanypath-2.1.1 → asanypath-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  45. {asanypath-2.1.1 → asanypath-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  46. {asanypath-2.1.1 → asanypath-2.3.0}/.github/dependabot.yml +0 -0
  47. {asanypath-2.1.1 → asanypath-2.3.0}/.github/workflows/publish.yml +0 -0
  48. {asanypath-2.1.1 → asanypath-2.3.0}/.gitignore +0 -0
  49. {asanypath-2.1.1 → asanypath-2.3.0}/CODE_OF_CONDUCT.md +0 -0
  50. {asanypath-2.1.1 → asanypath-2.3.0}/CONTRIBUTING.md +0 -0
  51. {asanypath-2.1.1 → asanypath-2.3.0}/LICENSE +0 -0
  52. {asanypath-2.1.1 → asanypath-2.3.0}/SECURITY.md +0 -0
  53. {asanypath-2.1.1 → asanypath-2.3.0}/asanypath.png +0 -0
  54. {asanypath-2.1.1 → asanypath-2.3.0}/compose.yml +0 -0
  55. {asanypath-2.1.1 → asanypath-2.3.0}/hatch_build.py +0 -0
  56. {asanypath-2.1.1 → asanypath-2.3.0}/scripts/bench.py +0 -0
  57. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/_credstore.py +0 -0
  58. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/asanypath.py +0 -0
  59. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/asanypath.pyi +0 -0
  60. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/batcher.py +0 -0
  61. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/common.py +0 -0
  62. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/exceptions.py +0 -0
  63. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/py.typed +0 -0
  64. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/sync.py +0 -0
  65. {asanypath-2.1.1 → asanypath-2.3.0}/src/asanypath/unsupported.py +0 -0
  66. {asanypath-2.1.1 → asanypath-2.3.0}/tests/__init__.py +0 -0
  67. {asanypath-2.1.1 → asanypath-2.3.0}/tests/conftest.py +0 -0
  68. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_asanypath.py +0 -0
  69. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_batcher.py +0 -0
  70. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_commonpathmixin.py +0 -0
  71. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_credstore.py +0 -0
  72. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_http_auth.py +0 -0
  73. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_py_typed.py +0 -0
  74. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_sync.py +0 -0
  75. {asanypath-2.1.1 → asanypath-2.3.0}/tests/test_syncpath.py +0 -0
  76. {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: Create virtual environment
53
- run: uv venv --python "${{ matrix.python-version }}"
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.1.1
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.1.0
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
- - `read_bytes()` / `write_bytes(data)`
444
- - `read_text(encoding)` / `write_text(data, encoding)`
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
- - `read_bytes()` / `write_bytes(data)`
389
- - `read_text(encoding)` / `write_text(data, encoding)`
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",
@@ -6,6 +6,7 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from ._transfer import TransferBudget as TransferBudget
9
10
  from ._version import __version__ as __version__
10
11
  from .artifactory import ArtifactoryPath as ArtifactoryPath
11
12
  from .asanypath import (
@@ -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))