asanypath 2.1.1__tar.gz → 2.2.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 (73) hide show
  1. {asanypath-2.1.1 → asanypath-2.2.0}/.github/workflows/unittests.yml +2 -5
  2. {asanypath-2.1.1 → asanypath-2.2.0}/.pre-commit-config.yaml +7 -1
  3. {asanypath-2.1.1 → asanypath-2.2.0}/PKG-INFO +131 -6
  4. {asanypath-2.1.1 → asanypath-2.2.0}/README.md +125 -4
  5. {asanypath-2.1.1 → asanypath-2.2.0}/pyproject.toml +23 -6
  6. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/__init__.py +2 -0
  7. asanypath-2.2.0/src/asanypath/_resume.py +43 -0
  8. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/_version.py +2 -2
  9. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/artifactory.py +52 -1
  10. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/azure.py +52 -1
  11. asanypath-2.2.0/src/asanypath/cache.py +175 -0
  12. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/cloud.py +199 -30
  13. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/ftp.py +44 -8
  14. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/gcs.py +41 -1
  15. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/http.py +4 -2
  16. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/local.py +22 -16
  17. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/options.py +30 -0
  18. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/s3.py +59 -0
  19. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/ssh.py +48 -7
  20. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_artifactory.py +37 -0
  21. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_azure.py +42 -0
  22. asanypath-2.2.0/tests/test_cache.py +242 -0
  23. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_cloudpathmixin.py +21 -0
  24. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_ftp.py +19 -0
  25. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_gcs.py +69 -0
  26. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_local.py +6 -0
  27. asanypath-2.2.0/tests/test_options.py +32 -0
  28. asanypath-2.2.0/tests/test_resume.py +198 -0
  29. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_s3.py +166 -0
  30. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_ssh.py +25 -0
  31. asanypath-2.1.1/uv.lock +0 -2831
  32. {asanypath-2.1.1 → asanypath-2.2.0}/.copilot/skills/async-python-lib/SKILL.md +0 -0
  33. {asanypath-2.1.1 → asanypath-2.2.0}/.copilot/skills/gitguro/SKILL.md +0 -0
  34. {asanypath-2.1.1 → asanypath-2.2.0}/.github/CODEOWNERS +0 -0
  35. {asanypath-2.1.1 → asanypath-2.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  36. {asanypath-2.1.1 → asanypath-2.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  37. {asanypath-2.1.1 → asanypath-2.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  38. {asanypath-2.1.1 → asanypath-2.2.0}/.github/dependabot.yml +0 -0
  39. {asanypath-2.1.1 → asanypath-2.2.0}/.github/workflows/publish.yml +0 -0
  40. {asanypath-2.1.1 → asanypath-2.2.0}/.gitignore +0 -0
  41. {asanypath-2.1.1 → asanypath-2.2.0}/CODE_OF_CONDUCT.md +0 -0
  42. {asanypath-2.1.1 → asanypath-2.2.0}/CONTRIBUTING.md +0 -0
  43. {asanypath-2.1.1 → asanypath-2.2.0}/LICENSE +0 -0
  44. {asanypath-2.1.1 → asanypath-2.2.0}/SECURITY.md +0 -0
  45. {asanypath-2.1.1 → asanypath-2.2.0}/asanypath.png +0 -0
  46. {asanypath-2.1.1 → asanypath-2.2.0}/compose.yml +0 -0
  47. {asanypath-2.1.1 → asanypath-2.2.0}/hatch_build.py +0 -0
  48. {asanypath-2.1.1 → asanypath-2.2.0}/scripts/bench.py +0 -0
  49. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/__init__.pyi +0 -0
  50. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/_credstore.py +0 -0
  51. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/_transfer.py +0 -0
  52. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/asanypath.py +0 -0
  53. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/asanypath.pyi +0 -0
  54. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/batcher.py +0 -0
  55. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/cli.py +0 -0
  56. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/common.py +0 -0
  57. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/exceptions.py +0 -0
  58. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/py.typed +0 -0
  59. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/sync.py +0 -0
  60. {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/unsupported.py +0 -0
  61. {asanypath-2.1.1 → asanypath-2.2.0}/tests/__init__.py +0 -0
  62. {asanypath-2.1.1 → asanypath-2.2.0}/tests/conftest.py +0 -0
  63. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_asanypath.py +0 -0
  64. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_batcher.py +0 -0
  65. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_cli.py +0 -0
  66. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_commonpathmixin.py +0 -0
  67. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_credstore.py +0 -0
  68. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_http.py +0 -0
  69. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_http_auth.py +0 -0
  70. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_py_typed.py +0 -0
  71. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_sync.py +0 -0
  72. {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_syncpath.py +0 -0
  73. {asanypath-2.1.1 → asanypath-2.2.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.2.0
4
4
  Summary: Async pathlib for local and cloud storage
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -19,11 +19,12 @@ 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.2.0
23
23
  Requires-Dist: msgspec>=0.22.0
24
24
  Requires-Dist: yarl>=1.2
25
25
  Provides-Extra: all
26
26
  Requires-Dist: aioftp>=0.21; extra == 'all'
27
+ Requires-Dist: diskcache>=5.6; extra == 'all'
27
28
  Requires-Dist: keyring>=24; extra == 'all'
28
29
  Provides-Extra: bench
29
30
  Requires-Dist: adlfs; extra == 'bench'
@@ -37,12 +38,15 @@ Requires-Dist: gcloud-aio-storage; extra == 'bench'
37
38
  Requires-Dist: gcsfs; extra == 'bench'
38
39
  Requires-Dist: requests; extra == 'bench'
39
40
  Requires-Dist: s3fs; extra == 'bench'
41
+ Provides-Extra: cache
42
+ Requires-Dist: diskcache>=5.6; extra == 'cache'
40
43
  Provides-Extra: cli
41
44
  Requires-Dist: rich-click>=1.9.7; extra == 'cli'
42
45
  Provides-Extra: credstore
43
46
  Requires-Dist: keyring>=24; extra == 'credstore'
44
47
  Provides-Extra: dev
45
48
  Requires-Dist: aioftp>=0.21; extra == 'dev'
49
+ Requires-Dist: diskcache>=5.6; extra == 'dev'
46
50
  Requires-Dist: keyring>=24; extra == 'dev'
47
51
  Requires-Dist: pre-commit>=4; extra == 'dev'
48
52
  Requires-Dist: pytest-asyncio>=1.3; extra == 'dev'
@@ -161,11 +165,131 @@ for child in p.parent.iterdir(): # yields synchronously
161
165
  Local paths (`file://` or bare `/path`) bypass the proxy entirely and use
162
166
  `pathlib.Path` directly — zero threading overhead.
163
167
 
168
+ ### Large uploads stream automatically
169
+
170
+ Writing or copying a large object never buffers the whole thing in memory — it
171
+ streams in bounded-size parts using each backend's native mechanism (S3
172
+ multipart, Azure block blobs, GCS resumable, SSH chunked writes, FTP data
173
+ connection, Artifactory streamed `PUT`). This is the **default** behaviour — no
174
+ flag, no API change:
175
+
176
+ ```python
177
+ # open("wb") spills to disk past 16 MiB and uploads in parts on close
178
+ async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
179
+ f.write(data)
180
+
181
+ # local -> cloud copy streams straight from the source file (async and sync)
182
+ await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
183
+ ```
184
+
185
+ Peak memory per upload is one part — **8 MiB by default**. Objects smaller than
186
+ one part upload in a single request. (`write_bytes(data)` / `write_text(...)`
187
+ take the whole payload in memory by definition — use `open("wb")` or `copy()`
188
+ for large data.)
189
+
190
+ **Tuning the memory footprint.** The part size *is* the max RAM held per
191
+ in-flight chunk (parts upload sequentially, so there is no concurrency
192
+ multiplier). Set it globally via env, or per call:
193
+
194
+ ```bash
195
+ export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
196
+ export ASANYPATH_SPOOL_MAX_SIZE=67108864 # 64 MiB before open("wb") spills to disk
197
+ ```
198
+
199
+ ```python
200
+ # per call: copy(chunk_size=...) or, for writes, open(..., buffering=...)
201
+ await src.copy("s3://bucket/huge.bin", chunk_size=32 * 1024 * 1024)
202
+ async with dst.open("wb", buffering=32 * 1024 * 1024) as f:
203
+ f.write(data)
204
+ ```
205
+
206
+ Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
207
+ 10,000 parts, so 8 MiB parts cap an object at ~80 GB). Backend minimums are
208
+ applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
209
+ 256 KiB multiple.
210
+
164
211
  Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
165
212
  Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
166
213
  operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
167
214
  `UnsupportedProtocolError` on use.
168
215
 
216
+ ### Download caching (opt-in)
217
+
218
+ Reading the same object or URL twice always re-downloads it — there is **no
219
+ cache by default**, and the read path is unchanged unless you opt in, so
220
+ non-caching callers pay zero overhead.
221
+
222
+ Enable it with the optional `cache` extra (`pip install asanypath[cache]`,
223
+ backed by [`diskcache`](https://pypi.org/project/diskcache/) — a pure-Python,
224
+ zero-dependency store). Opt in per read with `use_cache=True`:
225
+
226
+ ```python
227
+ from asanypath import AsAnyPath
228
+
229
+ p = AsAnyPath("s3://bucket/model.bin")
230
+ data = await p.read_bytes(use_cache=True) # first call fetches + stores
231
+ data = await p.read_bytes(use_cache=True) # subsequent calls served from disk
232
+ text = await p.read_text(use_cache=True)
233
+ ```
234
+
235
+ Configure the location and limits once (location defaults to
236
+ `~/.cache/asanypath`, honoring `$XDG_CACHE_HOME` and `$ASANYPATH_CACHE_DIR`):
237
+
238
+ ```python
239
+ from asanypath import configure_cache
240
+
241
+ configure_cache(
242
+ directory="/var/cache/asanypath", # default: ~/.cache/asanypath
243
+ size_limit=2 * 1024**3, # total on-disk cap in bytes (default 1 GiB, LRU)
244
+ ttl=3600, # per-entry expiry in seconds (default: none)
245
+ enabled=True, # make use_cache default to True process-wide
246
+ )
247
+ ```
248
+
249
+ Or via environment: `ASANYPATH_CACHE_DIR`, `ASANYPATH_CACHE_SIZE_LIMIT` (bytes),
250
+ `ASANYPATH_CACHE_TTL` (seconds).
251
+
252
+ Behaviour and trade-offs:
253
+
254
+ - **Off by default, opt-in per call.** With `configure_cache(enabled=True)` the
255
+ default flips process-wide; an explicit `use_cache=False` always bypasses.
256
+ - **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
257
+ FTP). Local paths are never cached.
258
+ - **Total-size bounded, LRU-evicted;** disk I/O runs in a worker thread so the
259
+ event loop stays responsive. Concurrent reads of the same key collapse to a
260
+ single fetch.
261
+ - **Staleness is on you.** Entries are keyed by URL with no revalidation — a
262
+ cached object that changes upstream is served stale until it expires (`ttl`)
263
+ or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
264
+
265
+ ### Resumable downloads
266
+
267
+ `download(dst)` streams an object to a local file and **resumes an interrupted
268
+ transfer** instead of starting over:
269
+
270
+ ```python
271
+ await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
272
+ # process killed mid-download... run the same call again — it continues
273
+ await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
274
+ ```
275
+
276
+ Bytes stream into `<dst>.part` with a `<dst>.part.meta` sidecar recording the
277
+ object's ETag (or `Last-Modified`) and total size. On resume the identity is
278
+ re-checked: if the upstream object is unchanged the download continues from the
279
+ saved offset; if it changed, the partial is discarded and it restarts cleanly.
280
+ On completion `<dst>.part` is atomically renamed to `dst`.
281
+
282
+ ```python
283
+ # tune the range-request size; disable resume to always start fresh
284
+ await path.download("/data/huge.bin", chunk_size=32 * 1024 * 1024, resume=False)
285
+ ```
286
+
287
+ Resume needs range reads **and** a HEAD probe, so it's available on the object
288
+ stores (S3/GCS/Azure) and Artifactory/HTTP where the server supports it.
289
+ Backends without those (e.g. FTP) download in a single pass automatically. This
290
+ needs no extra dependency — resume state is plain sidecar files, independent of
291
+ the optional download cache.
292
+
169
293
  ## Installation
170
294
 
171
295
  Most cloud backends (S3, GCS, Azure, Artifactory, HTTP) are included in the base
@@ -438,10 +562,11 @@ Factory that returns the appropriate path instance based on the URI scheme.
438
562
 
439
563
  All path implementations support:
440
564
 
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)`
565
+ - `open(mode, buffering)` — file-like streaming with lazy range reads; for writes, `buffering` sets the upload part size
566
+ - `copy(dst, recursive, chunk_size)` — copy file or tree (cross-backend); `chunk_size` tunes the upload part size
567
+ - `download(dst, resume, chunk_size)` — stream an object to a local file, resuming an interrupted transfer
568
+ - `read_bytes()` / `write_bytes(data)` — `read_bytes(use_cache=True)` serves from / stores in the opt-in [download cache](#download-caching-opt-in)
569
+ - `read_text(encoding)` / `write_text(data, encoding)` — `read_text` also accepts `use_cache`
445
570
  - `exists()` / `is_file()` / `is_dir()`
446
571
  - `stat()` / `checksums()` (cloud backends)
447
572
  - `mkdir(parents, exist_ok)` / `touch(exist_ok)`
@@ -106,11 +106,131 @@ for child in p.parent.iterdir(): # yields synchronously
106
106
  Local paths (`file://` or bare `/path`) bypass the proxy entirely and use
107
107
  `pathlib.Path` directly — zero threading overhead.
108
108
 
109
+ ### Large uploads stream automatically
110
+
111
+ Writing or copying a large object never buffers the whole thing in memory — it
112
+ streams in bounded-size parts using each backend's native mechanism (S3
113
+ multipart, Azure block blobs, GCS resumable, SSH chunked writes, FTP data
114
+ connection, Artifactory streamed `PUT`). This is the **default** behaviour — no
115
+ flag, no API change:
116
+
117
+ ```python
118
+ # open("wb") spills to disk past 16 MiB and uploads in parts on close
119
+ async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
120
+ f.write(data)
121
+
122
+ # local -> cloud copy streams straight from the source file (async and sync)
123
+ await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
124
+ ```
125
+
126
+ Peak memory per upload is one part — **8 MiB by default**. Objects smaller than
127
+ one part upload in a single request. (`write_bytes(data)` / `write_text(...)`
128
+ take the whole payload in memory by definition — use `open("wb")` or `copy()`
129
+ for large data.)
130
+
131
+ **Tuning the memory footprint.** The part size *is* the max RAM held per
132
+ in-flight chunk (parts upload sequentially, so there is no concurrency
133
+ multiplier). Set it globally via env, or per call:
134
+
135
+ ```bash
136
+ export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
137
+ export ASANYPATH_SPOOL_MAX_SIZE=67108864 # 64 MiB before open("wb") spills to disk
138
+ ```
139
+
140
+ ```python
141
+ # per call: copy(chunk_size=...) or, for writes, open(..., buffering=...)
142
+ await src.copy("s3://bucket/huge.bin", chunk_size=32 * 1024 * 1024)
143
+ async with dst.open("wb", buffering=32 * 1024 * 1024) as f:
144
+ f.write(data)
145
+ ```
146
+
147
+ Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
148
+ 10,000 parts, so 8 MiB parts cap an object at ~80 GB). Backend minimums are
149
+ applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
150
+ 256 KiB multiple.
151
+
109
152
  Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
110
153
  Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
111
154
  operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
112
155
  `UnsupportedProtocolError` on use.
113
156
 
157
+ ### Download caching (opt-in)
158
+
159
+ Reading the same object or URL twice always re-downloads it — there is **no
160
+ cache by default**, and the read path is unchanged unless you opt in, so
161
+ non-caching callers pay zero overhead.
162
+
163
+ Enable it with the optional `cache` extra (`pip install asanypath[cache]`,
164
+ backed by [`diskcache`](https://pypi.org/project/diskcache/) — a pure-Python,
165
+ zero-dependency store). Opt in per read with `use_cache=True`:
166
+
167
+ ```python
168
+ from asanypath import AsAnyPath
169
+
170
+ p = AsAnyPath("s3://bucket/model.bin")
171
+ data = await p.read_bytes(use_cache=True) # first call fetches + stores
172
+ data = await p.read_bytes(use_cache=True) # subsequent calls served from disk
173
+ text = await p.read_text(use_cache=True)
174
+ ```
175
+
176
+ Configure the location and limits once (location defaults to
177
+ `~/.cache/asanypath`, honoring `$XDG_CACHE_HOME` and `$ASANYPATH_CACHE_DIR`):
178
+
179
+ ```python
180
+ from asanypath import configure_cache
181
+
182
+ configure_cache(
183
+ directory="/var/cache/asanypath", # default: ~/.cache/asanypath
184
+ size_limit=2 * 1024**3, # total on-disk cap in bytes (default 1 GiB, LRU)
185
+ ttl=3600, # per-entry expiry in seconds (default: none)
186
+ enabled=True, # make use_cache default to True process-wide
187
+ )
188
+ ```
189
+
190
+ Or via environment: `ASANYPATH_CACHE_DIR`, `ASANYPATH_CACHE_SIZE_LIMIT` (bytes),
191
+ `ASANYPATH_CACHE_TTL` (seconds).
192
+
193
+ Behaviour and trade-offs:
194
+
195
+ - **Off by default, opt-in per call.** With `configure_cache(enabled=True)` the
196
+ default flips process-wide; an explicit `use_cache=False` always bypasses.
197
+ - **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
198
+ FTP). Local paths are never cached.
199
+ - **Total-size bounded, LRU-evicted;** disk I/O runs in a worker thread so the
200
+ event loop stays responsive. Concurrent reads of the same key collapse to a
201
+ single fetch.
202
+ - **Staleness is on you.** Entries are keyed by URL with no revalidation — a
203
+ cached object that changes upstream is served stale until it expires (`ttl`)
204
+ or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
205
+
206
+ ### Resumable downloads
207
+
208
+ `download(dst)` streams an object to a local file and **resumes an interrupted
209
+ transfer** instead of starting over:
210
+
211
+ ```python
212
+ await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
213
+ # process killed mid-download... run the same call again — it continues
214
+ await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
215
+ ```
216
+
217
+ Bytes stream into `<dst>.part` with a `<dst>.part.meta` sidecar recording the
218
+ object's ETag (or `Last-Modified`) and total size. On resume the identity is
219
+ re-checked: if the upstream object is unchanged the download continues from the
220
+ saved offset; if it changed, the partial is discarded and it restarts cleanly.
221
+ On completion `<dst>.part` is atomically renamed to `dst`.
222
+
223
+ ```python
224
+ # tune the range-request size; disable resume to always start fresh
225
+ await path.download("/data/huge.bin", chunk_size=32 * 1024 * 1024, resume=False)
226
+ ```
227
+
228
+ Resume needs range reads **and** a HEAD probe, so it's available on the object
229
+ stores (S3/GCS/Azure) and Artifactory/HTTP where the server supports it.
230
+ Backends without those (e.g. FTP) download in a single pass automatically. This
231
+ needs no extra dependency — resume state is plain sidecar files, independent of
232
+ the optional download cache.
233
+
114
234
  ## Installation
115
235
 
116
236
  Most cloud backends (S3, GCS, Azure, Artifactory, HTTP) are included in the base
@@ -383,10 +503,11 @@ Factory that returns the appropriate path instance based on the URI scheme.
383
503
 
384
504
  All path implementations support:
385
505
 
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)`
506
+ - `open(mode, buffering)` — file-like streaming with lazy range reads; for writes, `buffering` sets the upload part size
507
+ - `copy(dst, recursive, chunk_size)` — copy file or tree (cross-backend); `chunk_size` tunes the upload part size
508
+ - `download(dst, resume, chunk_size)` — stream an object to a local file, resuming an interrupted transfer
509
+ - `read_bytes()` / `write_bytes(data)` — `read_bytes(use_cache=True)` serves from / stores in the opt-in [download cache](#download-caching-opt-in)
510
+ - `read_text(encoding)` / `write_text(data, encoding)` — `read_text` also accepts `use_cache`
390
511
  - `exists()` / `is_file()` / `is_dir()`
391
512
  - `stat()` / `checksums()` (cloud backends)
392
513
  - `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
 
@@ -58,6 +58,7 @@ dev = [
58
58
  cli = ["rich-click>=1.9.7"]
59
59
  ftp = ["aioftp>=0.21"]
60
60
  credstore = ["keyring>=24"]
61
+ cache = ["diskcache>=5.6"]
61
62
  bench = [
62
63
  "asyncssh>=2.14",
63
64
  "fsspec",
@@ -71,7 +72,7 @@ bench = [
71
72
  "aiofiles",
72
73
  "requests",
73
74
  ]
74
- all = ["asanypath[credstore,ftp]"]
75
+ all = ["asanypath[credstore,ftp,cache]"]
75
76
  [project.scripts]
76
77
  asanypath = "asanypath.cli:main"
77
78
 
@@ -102,7 +103,7 @@ disable_error_code = ["attr-defined", "arg-type", "assignment"]
102
103
  module = "asanypath.local"
103
104
  disallow_untyped_defs = false
104
105
  warn_return_any = false
105
- disable_error_code = ["type-var", "misc", "override", "return-value"]
106
+ disable_error_code = ["type-var", "misc", "override", "return-value", "assignment", "no-redef"]
106
107
 
107
108
  [[tool.mypy.overrides]]
108
109
  module = "asanypath.common"
@@ -120,24 +121,40 @@ disable_error_code = ["override", "type-arg", "return-value", "assignment", "att
120
121
  module = ["asanypath.s3", "asanypath.gcs", "asanypath.azure", "asanypath.artifactory"]
121
122
  disallow_untyped_defs = false
122
123
  warn_return_any = false
123
- disable_error_code = ["no-any-return", "attr-defined", "assignment", "override"]
124
+ disable_error_code = ["no-any-return", "attr-defined", "assignment", "override", "arg-type", "misc"]
125
+
126
+ [[tool.mypy.overrides]]
127
+ module = ["asanypath.ssh", "asanypath.ftp"]
128
+ disallow_untyped_defs = false
129
+ warn_return_any = false
130
+ disable_error_code = ["attr-defined", "override", "arg-type", "misc", "no-any-return", "assignment"]
131
+
132
+ [[tool.mypy.overrides]]
133
+ module = "asanypath.sync"
134
+ disallow_untyped_defs = false
135
+ warn_return_any = false
136
+ disable_error_code = ["no-any-return", "misc", "attr-defined", "arg-type"]
124
137
 
125
138
  [[tool.mypy.overrides]]
126
139
  module = "asanypath.cloud"
127
140
  disallow_untyped_defs = false
128
141
  warn_return_any = false
129
- disable_error_code = ["no-any-return", "arg-type"]
142
+ disable_error_code = ["no-any-return", "arg-type", "attr-defined", "misc", "assignment"]
130
143
 
131
144
  [[tool.mypy.overrides]]
132
145
  module = "asanypath.asanypath"
133
146
  disallow_untyped_defs = false
134
147
  warn_return_any = false
135
- disable_error_code = ["no-any-return", "misc", "union-attr", "type-var"]
148
+ disable_error_code = ["no-any-return", "misc", "union-attr", "type-var", "attr-defined"]
136
149
 
137
150
  [[tool.mypy.overrides]]
138
151
  module = "asanypath.batcher"
139
152
  disallow_untyped_defs = false
140
153
 
154
+ [[tool.mypy.overrides]]
155
+ module = "diskcache.*"
156
+ ignore_missing_imports = true
157
+
141
158
  [tool.pytest.ini_options]
142
159
  asyncio_mode = "auto"
143
160
  testpaths = ["tests"]
@@ -13,6 +13,7 @@ from asanypath.asanypath import (
13
13
  register_protocol,
14
14
  )
15
15
  from asanypath.azure import AzurePath
16
+ from asanypath.cache import configure_cache
16
17
  from asanypath.cloud import CloudPathMixin
17
18
  from asanypath.common import CommonPurePathMixin
18
19
  from asanypath.exceptions import (
@@ -62,6 +63,7 @@ __all__ = [
62
63
  "GCSPath",
63
64
  "AzurePath",
64
65
  "ArtifactoryPath",
66
+ "configure_cache",
65
67
  "register_protocol",
66
68
  "list_protocols",
67
69
  "AsAnyPathError",
@@ -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))
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.1.1'
22
- __version_tuple__ = version_tuple = (2, 1, 1)
21
+ __version__ = version = '2.2.0'
22
+ __version_tuple__ = version_tuple = (2, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -9,6 +9,9 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
+ import os
13
+ import shutil
14
+ import tempfile
12
15
  from collections.abc import AsyncIterator, Mapping
13
16
  from datetime import datetime, timezone
14
17
  from os import getenv
@@ -31,11 +34,18 @@ from asanypath_native import (
31
34
  art_list_repos,
32
35
  art_put_batch,
33
36
  art_put_permission_target,
37
+ art_put_stream,
34
38
  art_storage_info,
35
39
  )
36
40
 
37
41
  from asanypath.cloud import CloudPathMixin
38
- from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
42
+ from asanypath.options import (
43
+ UPLOAD_CHUNK_SIZE,
44
+ AccessGrant,
45
+ AccessPolicy,
46
+ AccessPolicyPatch,
47
+ BackendOptions,
48
+ )
39
49
 
40
50
 
41
51
  def _jfrog_conf_token(base_url: str) -> str | None:
@@ -95,6 +105,9 @@ class ArtifactoryPath(CloudPathMixin):
95
105
 
96
106
  protocol: str = "art"
97
107
  _supports_range_read: bool = True
108
+ # Artifactory has no multipart API: objects at/above this size stream a single
109
+ # PUT body from disk (bounded memory) instead of buffering the whole object.
110
+ _STREAM_THRESHOLD = UPLOAD_CHUNK_SIZE
98
111
  _copy_batch_fn = staticmethod(art_copy_batch)
99
112
 
100
113
  @staticmethod
@@ -194,6 +207,44 @@ class ArtifactoryPath(CloudPathMixin):
194
207
  # Backend-specific operations
195
208
  # ------------------------------------------------------------------
196
209
 
210
+ async def _upload_buffer(
211
+ self, fileobj, size: int, *, backend_options=None, chunk_size: int | None = None
212
+ ) -> None:
213
+ """Upload a spooled write buffer; large objects stream a single PUT from disk."""
214
+ if size < (chunk_size or self._STREAM_THRESHOLD):
215
+ data = fileobj.read()
216
+ if backend_options is None:
217
+ await self.write_bytes(data)
218
+ else:
219
+ await self.write_bytes(data, backend_options=backend_options)
220
+ return
221
+ # Stream from a real file path when we have one (e.g. a local copy
222
+ # source); otherwise spill the anonymous buffer to a named temp file.
223
+ name = getattr(fileobj, "name", None)
224
+ if isinstance(name, str) and os.path.exists(name):
225
+ await self._stream_upload(name, size, backend_options=backend_options)
226
+ return
227
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
228
+ shutil.copyfileobj(fileobj, tmp, 1024 * 1024)
229
+ tmp_path = tmp.name
230
+ try:
231
+ await self._stream_upload(tmp_path, size, backend_options=backend_options)
232
+ finally:
233
+ os.unlink(tmp_path)
234
+
235
+ async def _stream_upload(self, file_path: str, size: int, *, backend_options=None) -> None:
236
+ options_json = (
237
+ msgspec.json.encode(backend_options).decode() if backend_options is not None else "{}"
238
+ )
239
+ await art_put_stream(
240
+ key=self._item_path,
241
+ file_path=file_path,
242
+ size=size,
243
+ options_json=options_json,
244
+ **self._native_kwargs,
245
+ )
246
+ type(self)._listing_cache.pop(self._listing_cache_key(), None)
247
+
197
248
  async def _storage_info(self) -> dict:
198
249
  """Fetch item metadata from the Artifactory storage API (cached).
199
250
 
@@ -12,6 +12,7 @@ https://learn.microsoft.com/en-us/rest/api/storageservices/blob-service-rest-api
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import base64
15
16
  from collections.abc import Mapping
16
17
  from datetime import datetime, timezone
17
18
  from os import getenv
@@ -31,11 +32,19 @@ from asanypath_native import (
31
32
  az_list_containers,
32
33
  az_presign,
33
34
  az_put_batch,
35
+ az_put_block,
36
+ az_put_block_list,
34
37
  az_put_container_acl,
35
38
  )
36
39
 
37
40
  from asanypath.cloud import CloudPathMixin
38
- from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
41
+ from asanypath.options import (
42
+ UPLOAD_CHUNK_SIZE,
43
+ AccessGrant,
44
+ AccessPolicy,
45
+ AccessPolicyPatch,
46
+ BackendOptions,
47
+ )
39
48
 
40
49
  AZURE_API_VERSION = "2023-11-03"
41
50
 
@@ -82,6 +91,8 @@ class AzurePath(CloudPathMixin):
82
91
 
83
92
  protocol: str = "az"
84
93
  _supports_range_read: bool = True
94
+ # Bytes per staged block; objects below one block upload in a single PUT.
95
+ _MULTIPART_PART_SIZE = UPLOAD_CHUNK_SIZE
85
96
  _is_dir_batch_fn = staticmethod(az_is_dir_batch)
86
97
  _copy_batch_fn = staticmethod(az_copy_batch)
87
98
 
@@ -180,6 +191,46 @@ class AzurePath(CloudPathMixin):
180
191
  # Backend-specific operations
181
192
  # ------------------------------------------------------------------
182
193
 
194
+ async def _upload_buffer(
195
+ self, fileobj, size: int, *, backend_options=None, chunk_size: int | None = None
196
+ ) -> None:
197
+ """Upload a spooled write buffer, using block blobs for large objects."""
198
+ part = chunk_size or self._MULTIPART_PART_SIZE
199
+ if size < part:
200
+ data = fileobj.read()
201
+ if backend_options is None:
202
+ await self.write_bytes(data)
203
+ else:
204
+ await self.write_bytes(data, backend_options=backend_options)
205
+ return
206
+ await self._block_upload(fileobj, part, backend_options=backend_options)
207
+
208
+ async def _block_upload(self, fileobj, part_size: int, *, backend_options=None) -> None:
209
+ """Stage fixed-size blocks then commit the list (memory bounded to one block)."""
210
+ options_json = (
211
+ msgspec.json.encode(backend_options).decode() if backend_options is not None else "{}"
212
+ )
213
+ block_ids: list[str] = []
214
+ index = 0
215
+ while True:
216
+ chunk = fileobj.read(part_size)
217
+ if not chunk:
218
+ break
219
+ # Block ids must be equal-length base64 strings.
220
+ block_id = base64.b64encode(f"{index:048d}".encode()).decode()
221
+ await az_put_block(
222
+ blob=self._item_path, block_id=block_id, data=chunk, **self._native_kwargs
223
+ )
224
+ block_ids.append(block_id)
225
+ index += 1
226
+ await az_put_block_list(
227
+ blob=self._item_path,
228
+ block_ids=block_ids,
229
+ options_json=options_json,
230
+ **self._native_kwargs,
231
+ )
232
+ type(self)._listing_cache.pop(self._listing_cache_key(), None)
233
+
183
234
  async def is_dir(self) -> bool:
184
235
  """Check if path is a directory prefix (has blobs with this prefix)."""
185
236
  return await az_is_dir(prefix=self._blob_path, **self._native_kwargs)