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.
Files changed (76) hide show
  1. {asanypath-2.3.0 → asanypath-2.3.1}/PKG-INFO +50 -12
  2. {asanypath-2.3.0 → asanypath-2.3.1}/README.md +48 -11
  3. {asanypath-2.3.0 → asanypath-2.3.1}/pyproject.toml +1 -0
  4. asanypath-2.3.1/scripts/bench_upload.py +181 -0
  5. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_transfer.py +78 -5
  6. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_version.py +2 -2
  7. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/azure.py +15 -13
  8. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/cli.py +5 -5
  9. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/cloud.py +36 -16
  10. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/gcs.py +6 -4
  11. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/local.py +0 -1
  12. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/options.py +2 -1
  13. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/s3.py +29 -16
  14. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_cloudpathmixin.py +3 -3
  15. asanypath-2.3.1/tests/test_upload.py +559 -0
  16. {asanypath-2.3.0 → asanypath-2.3.1}/.copilot/skills/async-python-lib/SKILL.md +0 -0
  17. {asanypath-2.3.0 → asanypath-2.3.1}/.copilot/skills/gitguro/SKILL.md +0 -0
  18. {asanypath-2.3.0 → asanypath-2.3.1}/.github/CODEOWNERS +0 -0
  19. {asanypath-2.3.0 → asanypath-2.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  20. {asanypath-2.3.0 → asanypath-2.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  21. {asanypath-2.3.0 → asanypath-2.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {asanypath-2.3.0 → asanypath-2.3.1}/.github/dependabot.yml +0 -0
  23. {asanypath-2.3.0 → asanypath-2.3.1}/.github/workflows/publish.yml +0 -0
  24. {asanypath-2.3.0 → asanypath-2.3.1}/.github/workflows/unittests.yml +0 -0
  25. {asanypath-2.3.0 → asanypath-2.3.1}/.gitignore +0 -0
  26. {asanypath-2.3.0 → asanypath-2.3.1}/.pre-commit-config.yaml +0 -0
  27. {asanypath-2.3.0 → asanypath-2.3.1}/CODE_OF_CONDUCT.md +0 -0
  28. {asanypath-2.3.0 → asanypath-2.3.1}/CONTRIBUTING.md +0 -0
  29. {asanypath-2.3.0 → asanypath-2.3.1}/LICENSE +0 -0
  30. {asanypath-2.3.0 → asanypath-2.3.1}/SECURITY.md +0 -0
  31. {asanypath-2.3.0 → asanypath-2.3.1}/asanypath.png +0 -0
  32. {asanypath-2.3.0 → asanypath-2.3.1}/compose.yml +0 -0
  33. {asanypath-2.3.0 → asanypath-2.3.1}/hatch_build.py +0 -0
  34. {asanypath-2.3.0 → asanypath-2.3.1}/scripts/bench.py +0 -0
  35. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/__init__.py +0 -0
  36. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/__init__.pyi +0 -0
  37. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_backend_opts.py +0 -0
  38. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_credstore.py +0 -0
  39. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/_resume.py +0 -0
  40. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/artifactory.py +0 -0
  41. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/asanypath.py +0 -0
  42. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/asanypath.pyi +0 -0
  43. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/batcher.py +0 -0
  44. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/cache.py +0 -0
  45. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/common.py +0 -0
  46. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/exceptions.py +0 -0
  47. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/ftp.py +0 -0
  48. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/http.py +0 -0
  49. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/py.typed +0 -0
  50. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/ssh.py +0 -0
  51. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/sync.py +0 -0
  52. {asanypath-2.3.0 → asanypath-2.3.1}/src/asanypath/unsupported.py +0 -0
  53. {asanypath-2.3.0 → asanypath-2.3.1}/tests/__init__.py +0 -0
  54. {asanypath-2.3.0 → asanypath-2.3.1}/tests/conftest.py +0 -0
  55. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_artifactory.py +0 -0
  56. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_asanypath.py +0 -0
  57. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_azure.py +0 -0
  58. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_backend_options.py +0 -0
  59. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_batcher.py +0 -0
  60. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_cache.py +0 -0
  61. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_cli.py +0 -0
  62. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_commonpathmixin.py +0 -0
  63. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_credstore.py +0 -0
  64. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_ftp.py +0 -0
  65. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_gcs.py +0 -0
  66. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_http.py +0 -0
  67. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_http_auth.py +0 -0
  68. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_local.py +0 -0
  69. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_options.py +0 -0
  70. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_py_typed.py +0 -0
  71. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_resume.py +0 -0
  72. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_s3.py +0 -0
  73. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_ssh.py +0 -0
  74. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_sync.py +0 -0
  75. {asanypath-2.3.0 → asanypath-2.3.1}/tests/test_syncpath.py +0 -0
  76. {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.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
@@ -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
- Uploads remain single writes; this does not introduce multipart uploads.
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, not metadata requests or
174
- other processes. Parallel range buffers are bounded by the active streams
175
- times the range size; cross-backend destinations still buffer the complete
176
- object for their existing write API. `floor=1` disables automatic splitting.
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
- Peak memory per upload is one part — **8 MiB by default**. Objects smaller than
222
- one part upload in a single request. (`write_bytes(data)` / `write_text(...)`
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 the memory footprint.** The part size *is* the max RAM held per
227
- in-flight chunk (parts upload sequentially, so there is no concurrency
228
- multiplier). Set it globally via env, or per call:
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
- Uploads remain single writes; this does not introduce multipart uploads.
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, not metadata requests or
119
- other processes. Parallel range buffers are bounded by the active streams
120
- times the range size; cross-backend destinations still buffer the complete
121
- object for their existing write API. `floor=1` disables automatic splitting.
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
- Peak memory per upload is one part — **8 MiB by default**. Objects smaller than
167
- one part upload in a single request. (`write_bytes(data)` / `write_text(...)`
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 the memory footprint.** The part size *is* the max RAM held per
172
- in-flight chunk (parts upload sequentially, so there is no concurrency
173
- multiplier). Set it globally via env, or per call:
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 |
@@ -59,6 +59,7 @@ cli = ["rich-click>=1.9.7"]
59
59
  ftp = ["aioftp>=0.21"]
60
60
  credstore = ["keyring>=24"]
61
61
  bench = [
62
+ "boto3",
62
63
  "asyncssh>=2.14",
63
64
  "fsspec",
64
65
  "s3fs",
@@ -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 size-aware range concurrency within one transfer operation."""
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 is None:
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
- async def iter_transfer_chunks(src: Any, chunk_size: int | None = None) -> AsyncIterator[bytes]:
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.0'
22
- __version_tuple__ = version_tuple = (2, 3, 0)
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(self, fileobj, part_size: int, *, backend_options=None) -> None:
209
- """Stage fixed-size blocks then commit the list (memory bounded to one block)."""
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
- block_ids: list[str] = []
214
- index = 0
215
- while True:
216
- chunk = fileobj.read(part_size)
217
- if not chunk:
218
- break
219
- # Block ids must be equal-length base64 strings.
220
- block_id = base64.b64encode(f"{index:048d}".encode()).decode()
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
- block_ids.append(block_id)
225
- index += 1
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="Streaming chunk size for local-destination transfers (default: auto; 0 disables)",
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 transfer streams when few large files are active (default: 8; 1 disables ranges)",
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="Streaming chunk size for local-destination transfers (default: auto; 0 disables)",
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 transfer streams when few large files are active (default: 8; 1 disables ranges)",
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 transfer streams when few large files are active (default: 8; 1 disables ranges)",
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(