asanypath 2.3.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.3.0 → asanypath-2.3.1}/PKG-INFO +50 -12
- {asanypath-2.3.0 → asanypath-2.3.1}/README.md +48 -11
- {asanypath-2.3.0 → asanypath-2.3.1}/pyproject.toml +1 -0
- asanypath-2.3.1/scripts/bench_upload.py +181 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_transfer.py +78 -5
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_version.py +2 -2
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/azure.py +15 -13
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/cli.py +5 -5
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/cloud.py +36 -16
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/gcs.py +6 -4
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/local.py +0 -1
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/options.py +2 -1
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/s3.py +29 -16
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_cloudpathmixin.py +3 -3
- asanypath-2.3.1/tests/test_upload.py +559 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.copilot/skills/async-python-lib/SKILL.md +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.copilot/skills/gitguro/SKILL.md +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.github/CODEOWNERS +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.github/dependabot.yml +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.github/workflows/publish.yml +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.github/workflows/unittests.yml +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.gitignore +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/.pre-commit-config.yaml +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/CODE_OF_CONDUCT.md +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/CONTRIBUTING.md +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/LICENSE +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/SECURITY.md +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/asanypath.png +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/compose.yml +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/hatch_build.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/scripts/bench.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/__init__.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/__init__.pyi +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_backend_opts.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_credstore.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_resume.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/artifactory.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/asanypath.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/asanypath.pyi +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/batcher.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/cache.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/common.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/exceptions.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/ftp.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/http.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/py.typed +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/ssh.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/sync.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/unsupported.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/__init__.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/conftest.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_artifactory.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_asanypath.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_azure.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_backend_options.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_batcher.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_cache.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_cli.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_commonpathmixin.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_credstore.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_ftp.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_gcs.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_http.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_http_auth.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_local.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_options.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_py_typed.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_resume.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_s3.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_ssh.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_sync.py +0 -0
- {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_syncpath.py +0 -0
- {asanypath-2.3.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.
|
|
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
|
|
@@ -31,6 +31,7 @@ Requires-Dist: aiobotocore; extra == 'bench'
|
|
|
31
31
|
Requires-Dist: aiofiles; extra == 'bench'
|
|
32
32
|
Requires-Dist: asyncssh>=2.14; extra == 'bench'
|
|
33
33
|
Requires-Dist: azure-storage-blob; extra == 'bench'
|
|
34
|
+
Requires-Dist: boto3; extra == 'bench'
|
|
34
35
|
Requires-Dist: cloudpathlib[all]; extra == 'bench'
|
|
35
36
|
Requires-Dist: fsspec; extra == 'bench'
|
|
36
37
|
Requires-Dist: gcloud-aio-storage; extra == 'bench'
|
|
@@ -157,7 +158,9 @@ shared reader for local downloads and source reads in cross-backend copies.
|
|
|
157
158
|
FTP/FTPS, sources without size metadata, and HTTP servers without advertised
|
|
158
159
|
range support retain single-stream downloads. Local-only copies and native
|
|
159
160
|
server-side copy/rename operations retain their existing implementations.
|
|
160
|
-
|
|
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.
|
|
161
164
|
|
|
162
165
|
Concurrent library copies share a default budget within the event loop. To
|
|
163
166
|
configure a batch explicitly:
|
|
@@ -170,10 +173,14 @@ with TransferBudget(floor=8, ceiling=32, min_range_size=8 * 1024 * 1024, per_fil
|
|
|
170
173
|
await asyncio.gather(*(src.copy(dst) for src, dst in pairs))
|
|
171
174
|
```
|
|
172
175
|
|
|
173
|
-
The ceiling bounds concurrent reads in this budget,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
177
184
|
Explicit `chunk_size` requests retain sequential streaming when splitting
|
|
178
185
|
would be inappropriate, and `chunk_size=0` disables streaming to local paths.
|
|
179
186
|
|
|
@@ -218,14 +225,24 @@ async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
|
|
|
218
225
|
await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
|
|
219
226
|
```
|
|
220
227
|
|
|
221
|
-
|
|
222
|
-
|
|
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(...)`
|
|
223
236
|
take the whole payload in memory by definition — use `open("wb")` or `copy()`
|
|
224
|
-
for large data.)
|
|
237
|
+
for large data; direct `write_bytes()` remains a single write.)
|
|
225
238
|
|
|
226
|
-
**Tuning
|
|
227
|
-
|
|
228
|
-
|
|
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:
|
|
229
246
|
|
|
230
247
|
```bash
|
|
231
248
|
export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
|
|
@@ -244,6 +261,11 @@ Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
|
|
|
244
261
|
applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
|
|
245
262
|
256 KiB multiple.
|
|
246
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
|
+
|
|
247
269
|
Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
|
|
248
270
|
Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
|
|
249
271
|
operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
|
|
@@ -510,6 +532,22 @@ uv run --extra bench python scripts/bench.py --backend all --rounds 3 --objects
|
|
|
510
532
|
uv run --extra bench python scripts/bench.py --backend art --rounds 3 --concurrency-levels 1,10,50
|
|
511
533
|
```
|
|
512
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
|
+
|
|
513
551
|
### S3 — live S3-compatible service (median of 20 rounds, 10 objects, c=1)
|
|
514
552
|
|
|
515
553
|
| Operation | asanypath | s3fs | aiobotocore | cloudpathlib |
|
|
@@ -102,7 +102,9 @@ shared reader for local downloads and source reads in cross-backend copies.
|
|
|
102
102
|
FTP/FTPS, sources without size metadata, and HTTP servers without advertised
|
|
103
103
|
range support retain single-stream downloads. Local-only copies and native
|
|
104
104
|
server-side copy/rename operations retain their existing implementations.
|
|
105
|
-
|
|
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.
|
|
106
108
|
|
|
107
109
|
Concurrent library copies share a default budget within the event loop. To
|
|
108
110
|
configure a batch explicitly:
|
|
@@ -115,10 +117,14 @@ with TransferBudget(floor=8, ceiling=32, min_range_size=8 * 1024 * 1024, per_fil
|
|
|
115
117
|
await asyncio.gather(*(src.copy(dst) for src, dst in pairs))
|
|
116
118
|
```
|
|
117
119
|
|
|
118
|
-
The ceiling bounds concurrent reads in this budget,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
122
128
|
Explicit `chunk_size` requests retain sequential streaming when splitting
|
|
123
129
|
would be inappropriate, and `chunk_size=0` disables streaming to local paths.
|
|
124
130
|
|
|
@@ -163,14 +169,24 @@ async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
|
|
|
163
169
|
await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
|
|
164
170
|
```
|
|
165
171
|
|
|
166
|
-
|
|
167
|
-
|
|
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(...)`
|
|
168
180
|
take the whole payload in memory by definition — use `open("wb")` or `copy()`
|
|
169
|
-
for large data.)
|
|
181
|
+
for large data; direct `write_bytes()` remains a single write.)
|
|
170
182
|
|
|
171
|
-
**Tuning
|
|
172
|
-
|
|
173
|
-
|
|
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:
|
|
174
190
|
|
|
175
191
|
```bash
|
|
176
192
|
export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
|
|
@@ -189,6 +205,11 @@ Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
|
|
|
189
205
|
applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
|
|
190
206
|
256 KiB multiple.
|
|
191
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
|
+
|
|
192
213
|
Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
|
|
193
214
|
Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
|
|
194
215
|
operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
|
|
@@ -455,6 +476,22 @@ uv run --extra bench python scripts/bench.py --backend all --rounds 3 --objects
|
|
|
455
476
|
uv run --extra bench python scripts/bench.py --backend art --rounds 3 --concurrency-levels 1,10,50
|
|
456
477
|
```
|
|
457
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
|
+
|
|
458
495
|
### S3 — live S3-compatible service (median of 20 rounds, 10 objects, c=1)
|
|
459
496
|
|
|
460
497
|
| Operation | asanypath | s3fs | aiobotocore | cloudpathlib |
|
|
@@ -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())
|
|
@@ -11,13 +11,13 @@ import inspect
|
|
|
11
11
|
import os
|
|
12
12
|
import shutil
|
|
13
13
|
import uuid
|
|
14
|
-
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
14
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
|
15
15
|
from contextlib import aclosing, asynccontextmanager
|
|
16
16
|
from contextvars import ContextVar, Token
|
|
17
17
|
from functools import wraps
|
|
18
18
|
from hashlib import sha256
|
|
19
19
|
from pathlib import Path
|
|
20
|
-
from typing import Any, ParamSpec, TypeVar
|
|
20
|
+
from typing import IO, Any, ParamSpec, TypeVar
|
|
21
21
|
|
|
22
22
|
from asanypath.exceptions import UnsupportedProtocolError
|
|
23
23
|
|
|
@@ -34,7 +34,12 @@ class RangeNotSupportedError(OSError):
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
class TransferBudget:
|
|
37
|
-
"""Share
|
|
37
|
+
"""Share range-read and multipart-upload concurrency across transfers.
|
|
38
|
+
|
|
39
|
+
``floor`` is a best-effort stream target divided among active files;
|
|
40
|
+
``ceiling`` bounds requests across this budget, and ``per_file_cap`` bounds
|
|
41
|
+
each file's fan-out. Upload workers reserve capacity before buffering.
|
|
42
|
+
"""
|
|
38
43
|
|
|
39
44
|
def __init__(
|
|
40
45
|
self,
|
|
@@ -80,7 +85,7 @@ async def transfer_scope() -> AsyncIterator[TransferBudget]:
|
|
|
80
85
|
if budget is None:
|
|
81
86
|
loop = asyncio.get_running_loop()
|
|
82
87
|
budget = getattr(loop, "_asanypath_transfer_budget", None)
|
|
83
|
-
if budget
|
|
88
|
+
if not isinstance(budget, TransferBudget):
|
|
84
89
|
budget = TransferBudget()
|
|
85
90
|
setattr(loop, "_asanypath_transfer_budget", budget)
|
|
86
91
|
if _registered_transfer.get() is budget:
|
|
@@ -98,7 +103,74 @@ async def transfer_scope() -> AsyncIterator[TransferBudget]:
|
|
|
98
103
|
_transfer_budget.reset(budget_token)
|
|
99
104
|
|
|
100
105
|
|
|
101
|
-
|
|
106
|
+
UploadResult = TypeVar("UploadResult")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _file_io(operation: Callable[[], UploadResult]) -> UploadResult:
|
|
110
|
+
task = asyncio.create_task(asyncio.to_thread(operation))
|
|
111
|
+
try:
|
|
112
|
+
return await asyncio.shield(task)
|
|
113
|
+
except asyncio.CancelledError:
|
|
114
|
+
# Join non-cancellable disk I/O before the caller can close its file.
|
|
115
|
+
await task
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def upload_parts(
|
|
120
|
+
fileobj: IO[bytes],
|
|
121
|
+
size: int,
|
|
122
|
+
part_size: int,
|
|
123
|
+
upload: Callable[[int, bytes], Awaitable[UploadResult]],
|
|
124
|
+
*,
|
|
125
|
+
parallel: bool = True,
|
|
126
|
+
) -> list[UploadResult]:
|
|
127
|
+
"""Upload bounded parts with backpressure, returning results in source order."""
|
|
128
|
+
if part_size < 1:
|
|
129
|
+
raise ValueError("upload part size must be positive")
|
|
130
|
+
async with transfer_scope() as budget:
|
|
131
|
+
workers = (
|
|
132
|
+
min(
|
|
133
|
+
budget.per_file_cap,
|
|
134
|
+
max(1, budget.floor // budget.active_files),
|
|
135
|
+
max(1, (size + part_size - 1) // part_size),
|
|
136
|
+
)
|
|
137
|
+
if parallel
|
|
138
|
+
else 1
|
|
139
|
+
)
|
|
140
|
+
read_lock = asyncio.Lock()
|
|
141
|
+
results: dict[int, UploadResult] = {}
|
|
142
|
+
part_number = 0
|
|
143
|
+
|
|
144
|
+
async def worker() -> None:
|
|
145
|
+
nonlocal part_number
|
|
146
|
+
while True:
|
|
147
|
+
# Reserve capacity before allocating a buffer, not just before I/O.
|
|
148
|
+
async with budget._slots:
|
|
149
|
+
async with read_lock:
|
|
150
|
+
chunk = await _file_io(lambda: fileobj.read(part_size))
|
|
151
|
+
if not chunk:
|
|
152
|
+
return
|
|
153
|
+
part_number += 1
|
|
154
|
+
number = part_number
|
|
155
|
+
try:
|
|
156
|
+
results[number] = await upload(number, chunk)
|
|
157
|
+
finally:
|
|
158
|
+
del chunk
|
|
159
|
+
|
|
160
|
+
tasks = [asyncio.create_task(worker()) for _ in range(workers)]
|
|
161
|
+
try:
|
|
162
|
+
await asyncio.gather(*tasks)
|
|
163
|
+
finally:
|
|
164
|
+
for task in tasks:
|
|
165
|
+
if not task.done():
|
|
166
|
+
task.cancel()
|
|
167
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
168
|
+
return [results[number] for number in sorted(results)]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def iter_transfer_chunks(
|
|
172
|
+
src: Any, chunk_size: int | None = None
|
|
173
|
+
) -> AsyncGenerator[bytes, None]:
|
|
102
174
|
"""Read supported sources in bounded, ordered, size-aware parallel ranges."""
|
|
103
175
|
async with transfer_scope() as budget:
|
|
104
176
|
if (
|
|
@@ -143,6 +215,7 @@ async def iter_transfer_chunks(src: Any, chunk_size: int | None = None) -> Async
|
|
|
143
215
|
|
|
144
216
|
async def read_range(start: int, end: int) -> bytes:
|
|
145
217
|
async with budget._slots:
|
|
218
|
+
data: bytes
|
|
146
219
|
if getattr(src, "_conditional_range_read", False) and any(
|
|
147
220
|
getattr(metadata, field, None)
|
|
148
221
|
for field in ("etag", "last_modified", "version_id")
|
|
@@ -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.3.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 3,
|
|
21
|
+
__version__ = version = '2.3.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 3, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -17,6 +17,7 @@ from collections.abc import Mapping
|
|
|
17
17
|
from datetime import datetime, timezone
|
|
18
18
|
from os import getenv
|
|
19
19
|
from types import SimpleNamespace
|
|
20
|
+
from uuid import uuid4
|
|
20
21
|
|
|
21
22
|
import msgspec
|
|
22
23
|
from asanypath_native import (
|
|
@@ -37,6 +38,7 @@ from asanypath_native import (
|
|
|
37
38
|
az_put_container_acl,
|
|
38
39
|
)
|
|
39
40
|
|
|
41
|
+
from asanypath._transfer import upload_parts
|
|
40
42
|
from asanypath.cloud import CloudPathMixin
|
|
41
43
|
from asanypath.options import (
|
|
42
44
|
UPLOAD_CHUNK_SIZE,
|
|
@@ -203,26 +205,26 @@ class AzurePath(CloudPathMixin):
|
|
|
203
205
|
else:
|
|
204
206
|
await self.write_bytes(data, backend_options=backend_options)
|
|
205
207
|
return
|
|
206
|
-
await self._block_upload(fileobj, part, backend_options=backend_options)
|
|
208
|
+
await self._block_upload(fileobj, part, size=size, backend_options=backend_options)
|
|
207
209
|
|
|
208
|
-
async def _block_upload(
|
|
209
|
-
|
|
210
|
+
async def _block_upload(
|
|
211
|
+
self, fileobj, part_size: int, *, size: int, backend_options=None
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Stage bounded concurrent blocks, then commit them in source order."""
|
|
210
214
|
options_json = (
|
|
211
215
|
msgspec.json.encode(backend_options).decode() if backend_options is not None else "{}"
|
|
212
216
|
)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
break
|
|
219
|
-
# Block ids must be equal-length base64 strings.
|
|
220
|
-
block_id = base64.b64encode(f"{index:048d}".encode()).decode()
|
|
217
|
+
upload_id = uuid4().hex
|
|
218
|
+
|
|
219
|
+
async def upload(index: int, chunk: bytes) -> str:
|
|
220
|
+
# Equal-length, upload-specific ids avoid mixing concurrent writers.
|
|
221
|
+
block_id = base64.b64encode(f"{upload_id}{index:016d}".encode()).decode()
|
|
221
222
|
await az_put_block(
|
|
222
223
|
blob=self._item_path, block_id=block_id, data=chunk, **self._native_kwargs
|
|
223
224
|
)
|
|
224
|
-
|
|
225
|
-
|
|
225
|
+
return block_id
|
|
226
|
+
|
|
227
|
+
block_ids = await upload_parts(fileobj, size, part_size, upload)
|
|
226
228
|
await az_put_block_list(
|
|
227
229
|
blob=self._item_path,
|
|
228
230
|
block_ids=block_ids,
|
|
@@ -1726,7 +1726,7 @@ def rm(paths: tuple[str, ...], token: str | None, force: bool, recursive: bool,
|
|
|
1726
1726
|
"--chunk-size",
|
|
1727
1727
|
default=None,
|
|
1728
1728
|
type=click.IntRange(min=0),
|
|
1729
|
-
help="
|
|
1729
|
+
help="Range/upload part size in bytes (default: auto; 0 disables local streaming)",
|
|
1730
1730
|
)
|
|
1731
1731
|
@click.option(
|
|
1732
1732
|
"--verbose",
|
|
@@ -1746,7 +1746,7 @@ def rm(paths: tuple[str, ...], token: str | None, force: bool, recursive: bool,
|
|
|
1746
1746
|
"--concurrency-floor",
|
|
1747
1747
|
default=DEFAULT_CONCURRENCY,
|
|
1748
1748
|
type=click.IntRange(min=1),
|
|
1749
|
-
help="Target
|
|
1749
|
+
help="Target range/upload streams (default: 8; 1 makes each file sequential)",
|
|
1750
1750
|
)
|
|
1751
1751
|
def mv(
|
|
1752
1752
|
paths: tuple[str, ...],
|
|
@@ -1878,7 +1878,7 @@ def mv(
|
|
|
1878
1878
|
"--chunk-size",
|
|
1879
1879
|
default=None,
|
|
1880
1880
|
type=click.IntRange(min=0),
|
|
1881
|
-
help="
|
|
1881
|
+
help="Range/upload part size in bytes (default: auto; 0 disables local streaming)",
|
|
1882
1882
|
)
|
|
1883
1883
|
@click.option("--recursive", "recursive", "-r", is_flag=True, help="Copy directories recursively")
|
|
1884
1884
|
@click.option(
|
|
@@ -1899,7 +1899,7 @@ def mv(
|
|
|
1899
1899
|
"--concurrency-floor",
|
|
1900
1900
|
default=DEFAULT_CONCURRENCY,
|
|
1901
1901
|
type=click.IntRange(min=1),
|
|
1902
|
-
help="Target
|
|
1902
|
+
help="Target range/upload streams (default: 8; 1 makes each file sequential)",
|
|
1903
1903
|
)
|
|
1904
1904
|
@click.option(
|
|
1905
1905
|
"--listing",
|
|
@@ -2335,7 +2335,7 @@ def presign(path: str, expires: int, method: str, token: str | None) -> None:
|
|
|
2335
2335
|
"--concurrency-floor",
|
|
2336
2336
|
default=DEFAULT_CONCURRENCY,
|
|
2337
2337
|
type=click.IntRange(min=1),
|
|
2338
|
-
help="Target
|
|
2338
|
+
help="Target range/upload streams (default: 8; 1 makes each file sequential)",
|
|
2339
2339
|
)
|
|
2340
2340
|
@click.option("--exclude", multiple=True, help="Exclude files matching glob pattern (repeatable)")
|
|
2341
2341
|
@click.option(
|