asanypath 2.2.0__tar.gz → 2.3.1__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 (78) hide show
  1. {asanypath-2.2.0 → asanypath-2.3.1}/PKG-INFO +103 -18
  2. {asanypath-2.2.0 → asanypath-2.3.1}/README.md +100 -12
  3. {asanypath-2.2.0 → asanypath-2.3.1}/pyproject.toml +2 -6
  4. asanypath-2.3.1/scripts/bench_upload.py +181 -0
  5. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/__init__.py +2 -0
  6. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/__init__.pyi +1 -0
  7. asanypath-2.3.1/src/asanypath/_backend_opts.py +61 -0
  8. asanypath-2.3.1/src/asanypath/_transfer.py +498 -0
  9. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/_version.py +2 -2
  10. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/artifactory.py +5 -0
  11. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/azure.py +22 -14
  12. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/cache.py +16 -20
  13. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/cli.py +135 -34
  14. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/cloud.py +82 -25
  15. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/gcs.py +12 -5
  16. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/http.py +49 -1
  17. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/local.py +17 -9
  18. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/options.py +2 -1
  19. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/s3.py +42 -17
  20. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/ssh.py +1 -0
  21. asanypath-2.3.1/tests/test_backend_options.py +109 -0
  22. asanypath-2.3.1/tests/test_cache.py +497 -0
  23. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_cli.py +135 -0
  24. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_cloudpathmixin.py +349 -7
  25. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_ftp.py +19 -0
  26. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_http.py +93 -1
  27. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_s3.py +20 -1
  28. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_ssh.py +29 -0
  29. asanypath-2.3.1/tests/test_upload.py +559 -0
  30. asanypath-2.2.0/src/asanypath/_transfer.py +0 -224
  31. asanypath-2.2.0/tests/test_cache.py +0 -242
  32. {asanypath-2.2.0 → asanypath-2.3.1}/.copilot/skills/async-python-lib/SKILL.md +0 -0
  33. {asanypath-2.2.0 → asanypath-2.3.1}/.copilot/skills/gitguro/SKILL.md +0 -0
  34. {asanypath-2.2.0 → asanypath-2.3.1}/.github/CODEOWNERS +0 -0
  35. {asanypath-2.2.0 → asanypath-2.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  36. {asanypath-2.2.0 → asanypath-2.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  37. {asanypath-2.2.0 → asanypath-2.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  38. {asanypath-2.2.0 → asanypath-2.3.1}/.github/dependabot.yml +0 -0
  39. {asanypath-2.2.0 → asanypath-2.3.1}/.github/workflows/publish.yml +0 -0
  40. {asanypath-2.2.0 → asanypath-2.3.1}/.github/workflows/unittests.yml +0 -0
  41. {asanypath-2.2.0 → asanypath-2.3.1}/.gitignore +0 -0
  42. {asanypath-2.2.0 → asanypath-2.3.1}/.pre-commit-config.yaml +0 -0
  43. {asanypath-2.2.0 → asanypath-2.3.1}/CODE_OF_CONDUCT.md +0 -0
  44. {asanypath-2.2.0 → asanypath-2.3.1}/CONTRIBUTING.md +0 -0
  45. {asanypath-2.2.0 → asanypath-2.3.1}/LICENSE +0 -0
  46. {asanypath-2.2.0 → asanypath-2.3.1}/SECURITY.md +0 -0
  47. {asanypath-2.2.0 → asanypath-2.3.1}/asanypath.png +0 -0
  48. {asanypath-2.2.0 → asanypath-2.3.1}/compose.yml +0 -0
  49. {asanypath-2.2.0 → asanypath-2.3.1}/hatch_build.py +0 -0
  50. {asanypath-2.2.0 → asanypath-2.3.1}/scripts/bench.py +0 -0
  51. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/_credstore.py +0 -0
  52. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/_resume.py +0 -0
  53. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/asanypath.py +0 -0
  54. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/asanypath.pyi +0 -0
  55. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/batcher.py +0 -0
  56. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/common.py +0 -0
  57. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/exceptions.py +0 -0
  58. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/ftp.py +0 -0
  59. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/py.typed +0 -0
  60. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/sync.py +0 -0
  61. {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/unsupported.py +0 -0
  62. {asanypath-2.2.0 → asanypath-2.3.1}/tests/__init__.py +0 -0
  63. {asanypath-2.2.0 → asanypath-2.3.1}/tests/conftest.py +0 -0
  64. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_artifactory.py +0 -0
  65. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_asanypath.py +0 -0
  66. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_azure.py +0 -0
  67. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_batcher.py +0 -0
  68. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_commonpathmixin.py +0 -0
  69. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_credstore.py +0 -0
  70. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_gcs.py +0 -0
  71. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_http_auth.py +0 -0
  72. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_local.py +0 -0
  73. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_options.py +0 -0
  74. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_py_typed.py +0 -0
  75. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_resume.py +0 -0
  76. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_sync.py +0 -0
  77. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_syncpath.py +0 -0
  78. {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_unsupported_path.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asanypath
3
- Version: 2.2.0
3
+ Version: 2.3.1
4
4
  Summary: Async pathlib for local and cloud storage
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -19,12 +19,11 @@ 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.2.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
26
26
  Requires-Dist: aioftp>=0.21; extra == 'all'
27
- Requires-Dist: diskcache>=5.6; extra == 'all'
28
27
  Requires-Dist: keyring>=24; extra == 'all'
29
28
  Provides-Extra: bench
30
29
  Requires-Dist: adlfs; extra == 'bench'
@@ -32,21 +31,19 @@ Requires-Dist: aiobotocore; extra == 'bench'
32
31
  Requires-Dist: aiofiles; extra == 'bench'
33
32
  Requires-Dist: asyncssh>=2.14; extra == 'bench'
34
33
  Requires-Dist: azure-storage-blob; extra == 'bench'
34
+ Requires-Dist: boto3; extra == 'bench'
35
35
  Requires-Dist: cloudpathlib[all]; extra == 'bench'
36
36
  Requires-Dist: fsspec; extra == 'bench'
37
37
  Requires-Dist: gcloud-aio-storage; extra == 'bench'
38
38
  Requires-Dist: gcsfs; extra == 'bench'
39
39
  Requires-Dist: requests; extra == 'bench'
40
40
  Requires-Dist: s3fs; extra == 'bench'
41
- Provides-Extra: cache
42
- Requires-Dist: diskcache>=5.6; extra == 'cache'
43
41
  Provides-Extra: cli
44
42
  Requires-Dist: rich-click>=1.9.7; extra == 'cli'
45
43
  Provides-Extra: credstore
46
44
  Requires-Dist: keyring>=24; extra == 'credstore'
47
45
  Provides-Extra: dev
48
46
  Requires-Dist: aioftp>=0.21; extra == 'dev'
49
- Requires-Dist: diskcache>=5.6; extra == 'dev'
50
47
  Requires-Dist: keyring>=24; extra == 'dev'
51
48
  Requires-Dist: pre-commit>=4; extra == 'dev'
52
49
  Requires-Dist: pytest-asyncio>=1.3; extra == 'dev'
@@ -149,6 +146,52 @@ await src.copy(dst, recursive=True)
149
146
  await path.rmdir(recursive=True)
150
147
  ```
151
148
 
149
+ Copies automatically use spare transfer concurrency for large ranged reads.
150
+ The default target is 8 streams, with an 8 MiB minimum range size and at most
151
+ 8 range streams per file. Files smaller than 16 MiB stay single-stream. When
152
+ enough files are active to meet the target, automatic copies use one request
153
+ per file instead of adding ranges. The target is best-effort, not a minimum
154
+ number of requests that must always be launched.
155
+
156
+ S3, GCS, Azure, Artifactory, SSH, and range-capable HTTP/HTTPS sources use the
157
+ shared reader for local downloads and source reads in cross-backend copies.
158
+ FTP/FTPS, sources without size metadata, and HTTP servers without advertised
159
+ range support retain single-stream downloads. Local-only copies and native
160
+ server-side copy/rename operations retain their existing implementations.
161
+ Large S3/Azure uploads also use concurrent parts within this shared budget.
162
+ GCS resumable sessions remain sequential because the protocol requires ordered
163
+ offsets.
164
+
165
+ Concurrent library copies share a default budget within the event loop. To
166
+ configure a batch explicitly:
167
+
168
+ ```python
169
+ import asyncio
170
+ from asanypath import TransferBudget
171
+
172
+ with TransferBudget(floor=8, ceiling=32, min_range_size=8 * 1024 * 1024, per_file_cap=8):
173
+ await asyncio.gather(*(src.copy(dst) for src, dst in pairs))
174
+ ```
175
+
176
+ The ceiling bounds concurrent range reads and upload parts in this budget,
177
+ not metadata requests, single-request writes, or other processes. Parallel
178
+ buffers are bounded by active streams times their chunk sizes. Cross-backend
179
+ copies spool the downloaded source to a temporary file before using the
180
+ destination's upload API. S3/Azure then upload concurrent parts; destinations
181
+ without a streaming upload implementation can still buffer the complete write.
182
+ Same-backend server-side copies are unchanged.
183
+ `floor=1` disables automatic range splitting and makes upload parts sequential.
184
+ Explicit `chunk_size` requests retain sequential streaming when splitting
185
+ would be inappropriate, and `chunk_size=0` disables streaming to local paths.
186
+
187
+ S3/Azure ETags, GCS generations, and HTTP conditional requests pin the source
188
+ version when available. Other sources are checked against fresh metadata
189
+ after the download, which cannot guarantee a snapshot if the backend does
190
+ not expose a reliable change token. Use `atomic=True` to keep an existing
191
+ local destination intact on failure. Moves remove the source only after a
192
+ successful copy. The updated Python package requires the matching native
193
+ extension for conditional cloud reads.
194
+
152
195
  For synchronous code (no `await` needed):
153
196
 
154
197
  ```python
@@ -182,14 +225,24 @@ async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
182
225
  await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
183
226
  ```
184
227
 
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(...)`
228
+ S3 multipart and Azure block uploads send **up to 8 parts concurrently by
229
+ default**, filling freed slots without waiting for a whole batch. Concurrent
230
+ files share the budget rather than each multiplying the target concurrency.
231
+ At the default 8 MiB part size, a single large upload buffers up to **64 MiB of
232
+ part payload**, plus file spooling, transport and runtime overhead. Native part
233
+ requests borrow immutable Python bytes instead of copying each buffer again.
234
+ Objects smaller than one part upload in a single request.
235
+ (`write_bytes(data)` / `write_text(...)`
187
236
  take the whole payload in memory by definition — use `open("wb")` or `copy()`
188
- for large data.)
237
+ for large data; direct `write_bytes()` remains a single write.)
189
238
 
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:
239
+ **Tuning throughput and memory.** Buffered part payload scales with part size
240
+ times concurrent upload slots. A slot is reserved before reading its part,
241
+ so queued uploads do not allocate unbounded buffers. Tune concurrency with
242
+ `TransferBudget(floor=..., ceiling=..., per_file_cap=...)`, or CLI
243
+ `--concurrency-floor` (also used by `cp`, `mv`, and `sync`). To limit a batch
244
+ to one in-flight part/range, use `TransferBudget(floor=1, ceiling=1)`.
245
+ Set part size globally via env, or per call:
193
246
 
194
247
  ```bash
195
248
  export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
@@ -208,6 +261,11 @@ Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
208
261
  applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
209
262
  256 KiB multiple.
210
263
 
264
+ On S3 failure or cancellation, in-flight workers are cancelled and joined
265
+ before aborting the multipart upload; completion lists remain in source order.
266
+ Azure only commits the ordered block list after all stages succeed; abandoned
267
+ uncommitted blocks are left for Azure's automatic expiry.
268
+
211
269
  Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
212
270
  Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
213
271
  operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
@@ -219,9 +277,8 @@ Reading the same object or URL twice always re-downloads it — there is **no
219
277
  cache by default**, and the read path is unchanged unless you opt in, so
220
278
  non-caching callers pay zero overhead.
221
279
 
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`:
280
+ Caching is included in the native extension with no extra dependencies or
281
+ installation extras. Opt in per read with `use_cache=True`:
225
282
 
226
283
  ```python
227
284
  from asanypath import AsAnyPath
@@ -240,7 +297,7 @@ from asanypath import configure_cache
240
297
 
241
298
  configure_cache(
242
299
  directory="/var/cache/asanypath", # default: ~/.cache/asanypath
243
- size_limit=2 * 1024**3, # total on-disk cap in bytes (default 1 GiB, LRU)
300
+ size_limit=2 * 1024**3, # stored key + payload budget (default 1 GiB, LRU)
244
301
  ttl=3600, # per-entry expiry in seconds (default: none)
245
302
  enabled=True, # make use_cache default to True process-wide
246
303
  )
@@ -255,9 +312,15 @@ Behaviour and trade-offs:
255
312
  default flips process-wide; an explicit `use_cache=False` always bypasses.
256
313
  - **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
257
314
  FTP). Local paths are never cached.
258
- - **Total-size bounded, LRU-evicted;** disk I/O runs in a worker thread so the
315
+ - **Size-bounded, LRU-evicted;** disk I/O runs in a worker thread so the
259
316
  event loop stays responsive. Concurrent reads of the same key collapse to a
260
- single fetch.
317
+ single fetch within a cache instance. The byte budget counts UTF-8 keys and
318
+ payloads; SQLite metadata and transaction journals add filesystem overhead.
319
+ Objects larger than the budget are returned without being cached.
320
+ - **Byte-only native storage:** SQLite stores raw bytes, never Python pickles.
321
+ The database lives under `native-v1/` in the configured cache directory.
322
+ Legacy DiskCache files are ignored, not migrated or deleted. Multiple
323
+ processes can share the new store; use the same size limit across them.
261
324
  - **Staleness is on you.** Entries are keyed by URL with no revalidation — a
262
325
  cached object that changes upstream is served stale until it expires (`ttl`)
263
326
  or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
@@ -356,6 +419,12 @@ asanypath cp /local/file.txt s3://bucket/file.txt
356
419
  # Copy cloud → local
357
420
  asanypath cp s3://bucket/file.txt /local/file.txt
358
421
 
422
+ # Allow 32 concurrent files; fill spare capacity toward 8 streams for large files
423
+ asanypath cp -r s3://bucket/data/ ./data/ -j 32 --concurrency-floor 8
424
+
425
+ # Disable automatic range splitting (also supported by mv and sync)
426
+ asanypath cp s3://bucket/file.txt ./file.txt --concurrency-floor 1
427
+
359
428
  # Delete a file
360
429
  asanypath rm s3://bucket/file.txt --force
361
430
 
@@ -463,6 +532,22 @@ uv run --extra bench python scripts/bench.py --backend all --rounds 3 --objects
463
532
  uv run --extra bench python scripts/bench.py --backend art --rounds 3 --concurrency-levels 1,10,50
464
533
  ```
465
534
 
535
+ For multipart upload throughput, use `scripts/bench_upload.py` with real shard
536
+ files and an explicitly selected S3-compatible endpoint:
537
+
538
+ ```bash
539
+ uv run --extra bench python scripts/bench_upload.py \
540
+ --destination s3://my-bucket/bench --endpoint-url https://my-storage-endpoint \
541
+ --part-mib 8 --streams 1 8 --rounds 3 /data/shard-001 /data/shard-002
542
+ ```
543
+
544
+ This compares asanypath and boto3 using one shared request budget per client.
545
+ It warms up each case, reports JSON timings and MiB/s, verifies uploaded contents
546
+ outside the timed interval, and deletes only the uniquely named benchmark
547
+ objects it created. Both clients use normal AWS credentials (or `--profile`).
548
+ Run with one large shard and again with several representative shards; results
549
+ from a local test server are not evidence of production endpoint throughput.
550
+
466
551
  ### S3 — live S3-compatible service (median of 20 rounds, 10 objects, c=1)
467
552
 
468
553
  | Operation | asanypath | s3fs | aiobotocore | cloudpathlib |
@@ -90,6 +90,52 @@ 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
+ Large S3/Azure uploads also use concurrent parts within this shared budget.
106
+ GCS resumable sessions remain sequential because the protocol requires ordered
107
+ offsets.
108
+
109
+ Concurrent library copies share a default budget within the event loop. To
110
+ configure a batch explicitly:
111
+
112
+ ```python
113
+ import asyncio
114
+ from asanypath import TransferBudget
115
+
116
+ with TransferBudget(floor=8, ceiling=32, min_range_size=8 * 1024 * 1024, per_file_cap=8):
117
+ await asyncio.gather(*(src.copy(dst) for src, dst in pairs))
118
+ ```
119
+
120
+ The ceiling bounds concurrent range reads and upload parts in this budget,
121
+ not metadata requests, single-request writes, or other processes. Parallel
122
+ buffers are bounded by active streams times their chunk sizes. Cross-backend
123
+ copies spool the downloaded source to a temporary file before using the
124
+ destination's upload API. S3/Azure then upload concurrent parts; destinations
125
+ without a streaming upload implementation can still buffer the complete write.
126
+ Same-backend server-side copies are unchanged.
127
+ `floor=1` disables automatic range splitting and makes upload parts sequential.
128
+ Explicit `chunk_size` requests retain sequential streaming when splitting
129
+ would be inappropriate, and `chunk_size=0` disables streaming to local paths.
130
+
131
+ S3/Azure ETags, GCS generations, and HTTP conditional requests pin the source
132
+ version when available. Other sources are checked against fresh metadata
133
+ after the download, which cannot guarantee a snapshot if the backend does
134
+ not expose a reliable change token. Use `atomic=True` to keep an existing
135
+ local destination intact on failure. Moves remove the source only after a
136
+ successful copy. The updated Python package requires the matching native
137
+ extension for conditional cloud reads.
138
+
93
139
  For synchronous code (no `await` needed):
94
140
 
95
141
  ```python
@@ -123,14 +169,24 @@ async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
123
169
  await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
124
170
  ```
125
171
 
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(...)`
172
+ S3 multipart and Azure block uploads send **up to 8 parts concurrently by
173
+ default**, filling freed slots without waiting for a whole batch. Concurrent
174
+ files share the budget rather than each multiplying the target concurrency.
175
+ At the default 8 MiB part size, a single large upload buffers up to **64 MiB of
176
+ part payload**, plus file spooling, transport and runtime overhead. Native part
177
+ requests borrow immutable Python bytes instead of copying each buffer again.
178
+ Objects smaller than one part upload in a single request.
179
+ (`write_bytes(data)` / `write_text(...)`
128
180
  take the whole payload in memory by definition — use `open("wb")` or `copy()`
129
- for large data.)
181
+ for large data; direct `write_bytes()` remains a single write.)
130
182
 
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:
183
+ **Tuning throughput and memory.** Buffered part payload scales with part size
184
+ times concurrent upload slots. A slot is reserved before reading its part,
185
+ so queued uploads do not allocate unbounded buffers. Tune concurrency with
186
+ `TransferBudget(floor=..., ceiling=..., per_file_cap=...)`, or CLI
187
+ `--concurrency-floor` (also used by `cp`, `mv`, and `sync`). To limit a batch
188
+ to one in-flight part/range, use `TransferBudget(floor=1, ceiling=1)`.
189
+ Set part size globally via env, or per call:
134
190
 
135
191
  ```bash
136
192
  export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
@@ -149,6 +205,11 @@ Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
149
205
  applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
150
206
  256 KiB multiple.
151
207
 
208
+ On S3 failure or cancellation, in-flight workers are cancelled and joined
209
+ before aborting the multipart upload; completion lists remain in source order.
210
+ Azure only commits the ordered block list after all stages succeed; abandoned
211
+ uncommitted blocks are left for Azure's automatic expiry.
212
+
152
213
  Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
153
214
  Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
154
215
  operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
@@ -160,9 +221,8 @@ Reading the same object or URL twice always re-downloads it — there is **no
160
221
  cache by default**, and the read path is unchanged unless you opt in, so
161
222
  non-caching callers pay zero overhead.
162
223
 
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`:
224
+ Caching is included in the native extension with no extra dependencies or
225
+ installation extras. Opt in per read with `use_cache=True`:
166
226
 
167
227
  ```python
168
228
  from asanypath import AsAnyPath
@@ -181,7 +241,7 @@ from asanypath import configure_cache
181
241
 
182
242
  configure_cache(
183
243
  directory="/var/cache/asanypath", # default: ~/.cache/asanypath
184
- size_limit=2 * 1024**3, # total on-disk cap in bytes (default 1 GiB, LRU)
244
+ size_limit=2 * 1024**3, # stored key + payload budget (default 1 GiB, LRU)
185
245
  ttl=3600, # per-entry expiry in seconds (default: none)
186
246
  enabled=True, # make use_cache default to True process-wide
187
247
  )
@@ -196,9 +256,15 @@ Behaviour and trade-offs:
196
256
  default flips process-wide; an explicit `use_cache=False` always bypasses.
197
257
  - **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
198
258
  FTP). Local paths are never cached.
199
- - **Total-size bounded, LRU-evicted;** disk I/O runs in a worker thread so the
259
+ - **Size-bounded, LRU-evicted;** disk I/O runs in a worker thread so the
200
260
  event loop stays responsive. Concurrent reads of the same key collapse to a
201
- single fetch.
261
+ single fetch within a cache instance. The byte budget counts UTF-8 keys and
262
+ payloads; SQLite metadata and transaction journals add filesystem overhead.
263
+ Objects larger than the budget are returned without being cached.
264
+ - **Byte-only native storage:** SQLite stores raw bytes, never Python pickles.
265
+ The database lives under `native-v1/` in the configured cache directory.
266
+ Legacy DiskCache files are ignored, not migrated or deleted. Multiple
267
+ processes can share the new store; use the same size limit across them.
202
268
  - **Staleness is on you.** Entries are keyed by URL with no revalidation — a
203
269
  cached object that changes upstream is served stale until it expires (`ttl`)
204
270
  or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
@@ -297,6 +363,12 @@ asanypath cp /local/file.txt s3://bucket/file.txt
297
363
  # Copy cloud → local
298
364
  asanypath cp s3://bucket/file.txt /local/file.txt
299
365
 
366
+ # Allow 32 concurrent files; fill spare capacity toward 8 streams for large files
367
+ asanypath cp -r s3://bucket/data/ ./data/ -j 32 --concurrency-floor 8
368
+
369
+ # Disable automatic range splitting (also supported by mv and sync)
370
+ asanypath cp s3://bucket/file.txt ./file.txt --concurrency-floor 1
371
+
300
372
  # Delete a file
301
373
  asanypath rm s3://bucket/file.txt --force
302
374
 
@@ -404,6 +476,22 @@ uv run --extra bench python scripts/bench.py --backend all --rounds 3 --objects
404
476
  uv run --extra bench python scripts/bench.py --backend art --rounds 3 --concurrency-levels 1,10,50
405
477
  ```
406
478
 
479
+ For multipart upload throughput, use `scripts/bench_upload.py` with real shard
480
+ files and an explicitly selected S3-compatible endpoint:
481
+
482
+ ```bash
483
+ uv run --extra bench python scripts/bench_upload.py \
484
+ --destination s3://my-bucket/bench --endpoint-url https://my-storage-endpoint \
485
+ --part-mib 8 --streams 1 8 --rounds 3 /data/shard-001 /data/shard-002
486
+ ```
487
+
488
+ This compares asanypath and boto3 using one shared request budget per client.
489
+ It warms up each case, reports JSON timings and MiB/s, verifies uploaded contents
490
+ outside the timed interval, and deletes only the uniquely named benchmark
491
+ objects it created. Both clients use normal AWS credentials (or `--profile`).
492
+ Run with one large shard and again with several representative shards; results
493
+ from a local test server are not evidence of production endpoint throughput.
494
+
407
495
  ### S3 — live S3-compatible service (median of 20 rounds, 10 objects, c=1)
408
496
 
409
497
  | Operation | asanypath | s3fs | aiobotocore | cloudpathlib |
@@ -58,8 +58,8 @@ 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"]
62
61
  bench = [
62
+ "boto3",
63
63
  "asyncssh>=2.14",
64
64
  "fsspec",
65
65
  "s3fs",
@@ -72,7 +72,7 @@ bench = [
72
72
  "aiofiles",
73
73
  "requests",
74
74
  ]
75
- all = ["asanypath[credstore,ftp,cache]"]
75
+ all = ["asanypath[credstore,ftp]"]
76
76
  [project.scripts]
77
77
  asanypath = "asanypath.cli:main"
78
78
 
@@ -151,10 +151,6 @@ disable_error_code = ["no-any-return", "misc", "union-attr", "type-var", "attr-d
151
151
  module = "asanypath.batcher"
152
152
  disallow_untyped_defs = false
153
153
 
154
- [[tool.mypy.overrides]]
155
- module = "diskcache.*"
156
- ignore_missing_imports = true
157
-
158
154
  [tool.pytest.ini_options]
159
155
  asyncio_mode = "auto"
160
156
  testpaths = ["tests"]
@@ -0,0 +1,181 @@
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
+ """Compare bounded multipart uploads of real files to an explicitly chosen S3 prefix.
8
+
9
+ Uses normal AWS credential resolution; never reads repository credential files.
10
+ Each case warms up once, uploads to unique keys, verifies contents outside the
11
+ timed interval, and deletes only its own objects. Timings include public copy API
12
+ overhead for asanypath and upload_file API overhead for boto3.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import hashlib
20
+ import json
21
+ import statistics
22
+ import time
23
+ from collections.abc import Awaitable, Iterable
24
+ from pathlib import Path
25
+ from uuid import uuid4
26
+
27
+ from asanypath import AsAnyPath, TransferBudget
28
+
29
+
30
+ async def finish_uploads(uploads: Iterable[Awaitable[object]]) -> None:
31
+ pending = asyncio.gather(*uploads, return_exceptions=True)
32
+ try:
33
+ results = await asyncio.shield(pending)
34
+ except asyncio.CancelledError:
35
+ await pending
36
+ raise
37
+ for result in results:
38
+ if isinstance(result, BaseException):
39
+ raise result
40
+
41
+
42
+ def positive(value: str) -> int:
43
+ result = int(value)
44
+ if result < 1:
45
+ raise argparse.ArgumentTypeError("must be positive")
46
+ return result
47
+
48
+
49
+ def digest(path: Path) -> bytes:
50
+ checksum = hashlib.sha256()
51
+ with path.open("rb") as file:
52
+ while chunk := file.read(8 * 1024 * 1024):
53
+ checksum.update(chunk)
54
+ return checksum.digest()
55
+
56
+
57
+ async def main() -> None:
58
+ parser = argparse.ArgumentParser(description=__doc__)
59
+ parser.add_argument("files", nargs="+", type=Path, help="Representative local shard files")
60
+ parser.add_argument("--destination", required=True, help="s3://bucket/benchmark-prefix")
61
+ parser.add_argument("--endpoint-url", help="The storage endpoint used by both clients")
62
+ parser.add_argument("--profile", help="AWS profile used by both clients")
63
+ parser.add_argument("--streams", nargs="+", type=positive, default=[1, 8])
64
+ parser.add_argument("--part-mib", type=positive, default=8)
65
+ parser.add_argument("--rounds", type=positive, default=3)
66
+ parser.add_argument(
67
+ "--implementations",
68
+ nargs="+",
69
+ choices=["asanypath", "boto3"],
70
+ default=["asanypath", "boto3"],
71
+ )
72
+ args = parser.parse_args()
73
+ if not args.destination.startswith("s3://") or not args.destination[5:].split("/")[0]:
74
+ parser.error("--destination must be an s3://bucket/prefix URI")
75
+ if args.part_mib < 5:
76
+ parser.error("--part-mib must be at least 5 for S3")
77
+ for file in args.files:
78
+ if not file.is_file():
79
+ parser.error(f"Not a file: {file}")
80
+ kwargs = {}
81
+ if args.endpoint_url:
82
+ kwargs["endpoint_url"] = args.endpoint_url
83
+ if args.profile:
84
+ kwargs["profile"] = args.profile
85
+ part_size = args.part_mib * 1024 * 1024
86
+ total = sum(file.stat().st_size for file in args.files)
87
+ expected = [digest(file) for file in args.files]
88
+ base = args.destination.rstrip("/") + "/asanypath-upload-bench-" + uuid4().hex
89
+ bucket = base[5:].split("/", 1)[0]
90
+
91
+ for streams in args.streams:
92
+ for implementation in args.implementations:
93
+ client = transfer = None
94
+ if implementation == "boto3":
95
+ import boto3
96
+ from boto3.s3.transfer import S3Transfer, TransferConfig
97
+ from botocore.config import Config
98
+
99
+ client = boto3.Session(profile_name=args.profile).client(
100
+ "s3",
101
+ endpoint_url=args.endpoint_url,
102
+ config=Config(max_pool_connections=max(10, streams)),
103
+ )
104
+ # One manager shares its request budget across all files.
105
+ transfer = S3Transfer(
106
+ client,
107
+ TransferConfig(
108
+ multipart_threshold=part_size,
109
+ multipart_chunksize=part_size,
110
+ max_concurrency=streams,
111
+ preferred_transfer_client="classic",
112
+ ),
113
+ )
114
+ samples = []
115
+ try:
116
+ for round_number in range(args.rounds + 1):
117
+ uris = [
118
+ f"{base}/{implementation}/{streams}/{round_number}/{index}"
119
+ for index in range(len(args.files))
120
+ ]
121
+ destinations = [AsAnyPath(uri, **kwargs) for uri in uris]
122
+ try:
123
+ start = time.perf_counter()
124
+ if implementation == "asanypath":
125
+ with TransferBudget(
126
+ floor=streams, ceiling=streams, per_file_cap=streams
127
+ ):
128
+ await finish_uploads(
129
+ (
130
+ AsAnyPath(file).copy(dst, chunk_size=part_size)
131
+ for file, dst in zip(args.files, destinations, strict=True)
132
+ )
133
+ )
134
+ else:
135
+ await finish_uploads(
136
+ (
137
+ asyncio.to_thread(
138
+ transfer.upload_file,
139
+ str(file),
140
+ bucket,
141
+ uri[5:].split("/", 1)[1],
142
+ )
143
+ for file, uri in zip(args.files, uris, strict=True)
144
+ )
145
+ )
146
+ elapsed = time.perf_counter() - start
147
+ for dst, checksum in zip(destinations, expected, strict=True):
148
+ actual = hashlib.sha256()
149
+ async for chunk in dst.iter_bytes(part_size):
150
+ actual.update(chunk)
151
+ if actual.digest() != checksum:
152
+ raise OSError(f"Uploaded content verification failed: {dst}")
153
+ if round_number:
154
+ samples.append(elapsed)
155
+ finally:
156
+ await asyncio.gather(*(dst.unlink(missing_ok=True) for dst in destinations))
157
+ median = statistics.median(samples)
158
+ print(
159
+ json.dumps(
160
+ {
161
+ "implementation": implementation,
162
+ "streams": streams,
163
+ "files": len(args.files),
164
+ "bytes": total,
165
+ "part_bytes": part_size,
166
+ "seconds": samples,
167
+ "median_seconds": median,
168
+ "median_mib_per_second": total / (1024 * 1024) / median,
169
+ }
170
+ ),
171
+ flush=True,
172
+ )
173
+ finally:
174
+ if transfer is not None:
175
+ await asyncio.to_thread(transfer.__exit__, None, None, None)
176
+ if client is not None:
177
+ client.close()
178
+
179
+
180
+ if __name__ == "__main__":
181
+ asyncio.run(main())
@@ -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,
@@ -53,6 +54,7 @@ __all__ = [
53
54
  "AsAnyPath",
54
55
  "AsyncPath",
55
56
  "SyncPath",
57
+ "TransferBudget",
56
58
  "BackendOptions",
57
59
  "AccessGrant",
58
60
  "AccessPolicy",
@@ -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 (