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.
- {asanypath-2.2.0 → asanypath-2.3.1}/PKG-INFO +103 -18
- {asanypath-2.2.0 → asanypath-2.3.1}/README.md +100 -12
- {asanypath-2.2.0 → asanypath-2.3.1}/pyproject.toml +2 -6
- asanypath-2.3.1/scripts/bench_upload.py +181 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/__init__.py +2 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/__init__.pyi +1 -0
- asanypath-2.3.1/src/asanypath/_backend_opts.py +61 -0
- asanypath-2.3.1/src/asanypath/_transfer.py +498 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/_version.py +2 -2
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/artifactory.py +5 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/azure.py +22 -14
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/cache.py +16 -20
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/cli.py +135 -34
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/cloud.py +82 -25
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/gcs.py +12 -5
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/http.py +49 -1
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/local.py +17 -9
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/options.py +2 -1
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/s3.py +42 -17
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/ssh.py +1 -0
- asanypath-2.3.1/tests/test_backend_options.py +109 -0
- asanypath-2.3.1/tests/test_cache.py +497 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_cli.py +135 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_cloudpathmixin.py +349 -7
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_ftp.py +19 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_http.py +93 -1
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_s3.py +20 -1
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_ssh.py +29 -0
- asanypath-2.3.1/tests/test_upload.py +559 -0
- asanypath-2.2.0/src/asanypath/_transfer.py +0 -224
- asanypath-2.2.0/tests/test_cache.py +0 -242
- {asanypath-2.2.0 → asanypath-2.3.1}/.copilot/skills/async-python-lib/SKILL.md +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.copilot/skills/gitguro/SKILL.md +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.github/CODEOWNERS +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.github/dependabot.yml +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.github/workflows/publish.yml +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.github/workflows/unittests.yml +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.gitignore +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/.pre-commit-config.yaml +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/CODE_OF_CONDUCT.md +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/CONTRIBUTING.md +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/LICENSE +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/SECURITY.md +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/asanypath.png +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/compose.yml +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/hatch_build.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/scripts/bench.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/_credstore.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/_resume.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/asanypath.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/asanypath.pyi +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/batcher.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/common.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/exceptions.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/ftp.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/py.typed +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/sync.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/src/asanypath/unsupported.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/__init__.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/conftest.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_artifactory.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_asanypath.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_azure.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_batcher.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_commonpathmixin.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_credstore.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_gcs.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_http_auth.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_local.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_options.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_py_typed.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_resume.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_sync.py +0 -0
- {asanypath-2.2.0 → asanypath-2.3.1}/tests/test_syncpath.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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, #
|
|
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
|
-
- **
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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, #
|
|
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
|
-
- **
|
|
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
|
|
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",
|