asanypath 2.0.1__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {asanypath-2.0.1 → asanypath-2.1.0}/.github/workflows/publish.yml +2 -2
- {asanypath-2.0.1 → asanypath-2.1.0}/.github/workflows/unittests.yml +1 -1
- {asanypath-2.0.1 → asanypath-2.1.0}/PKG-INFO +50 -39
- {asanypath-2.0.1 → asanypath-2.1.0}/README.md +47 -33
- {asanypath-2.0.1 → asanypath-2.1.0}/pyproject.toml +2 -2
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/_version.py +2 -2
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/artifactory.py +14 -7
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/azure.py +4 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/cli.py +1 -1
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/cloud.py +62 -14
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/ftp.py +1 -1
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/gcs.py +4 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/http.py +2 -5
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/s3.py +76 -62
- asanypath-2.1.0/src/asanypath/ssh.py +594 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath_native.pyi +26 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_cli.py +3 -15
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_s3.py +80 -54
- asanypath-2.1.0/tests/test_ssh.py +560 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/uv.lock +15 -21
- asanypath-2.0.1/src/asanypath/ssh.py +0 -776
- asanypath-2.0.1/tests/test_ssh.py +0 -995
- {asanypath-2.0.1 → asanypath-2.1.0}/.copilot/skills/async-python-lib/SKILL.md +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/.copilot/skills/gitguro/SKILL.md +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/.github/CODEOWNERS +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/.github/dependabot.yml +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/.gitignore +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/.pre-commit-config.yaml +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/CODE_OF_CONDUCT.md +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/CONTRIBUTING.md +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/LICENSE +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/SECURITY.md +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/asanypath.png +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/compose.yml +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/hatch_build.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/scripts/bench.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/__init__.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/__init__.pyi +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/_credstore.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/_transfer.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/asanypath.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/asanypath.pyi +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/batcher.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/common.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/exceptions.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/local.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/options.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/sync.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/unsupported.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/__init__.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/conftest.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_artifactory.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_asanypath.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_azure.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_batcher.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_cloudpathmixin.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_commonpathmixin.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_credstore.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_ftp.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_gcs.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_http.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_http_auth.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_local.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_sync.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_syncpath.py +0 -0
- {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_unsupported_path.py +0 -0
|
@@ -102,7 +102,7 @@ jobs:
|
|
|
102
102
|
rm rust/Cargo.toml.bak
|
|
103
103
|
|
|
104
104
|
- name: Set up uv
|
|
105
|
-
uses: astral-sh/setup-uv@
|
|
105
|
+
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
106
106
|
with:
|
|
107
107
|
enable-cache: false
|
|
108
108
|
|
|
@@ -128,7 +128,7 @@ jobs:
|
|
|
128
128
|
persist-credentials: false
|
|
129
129
|
|
|
130
130
|
- name: Set up uv
|
|
131
|
-
uses: astral-sh/setup-uv@
|
|
131
|
+
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
132
132
|
with:
|
|
133
133
|
enable-cache: false
|
|
134
134
|
|
|
@@ -44,7 +44,7 @@ jobs:
|
|
|
44
44
|
python-version: ${{ matrix.python-version }}
|
|
45
45
|
|
|
46
46
|
- name: Set up uv
|
|
47
|
-
uses: astral-sh/setup-uv@
|
|
47
|
+
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
48
48
|
|
|
49
49
|
- name: Ruff checks
|
|
50
50
|
run: uvx ruff check src tests && uvx ruff format --check src tests
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: asanypath
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Async pathlib for local and cloud storage
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -19,17 +19,17 @@ 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.1.0
|
|
23
23
|
Requires-Dist: msgspec>=0.21.1
|
|
24
24
|
Requires-Dist: yarl>=1.2
|
|
25
25
|
Provides-Extra: all
|
|
26
26
|
Requires-Dist: aioftp>=0.21; extra == 'all'
|
|
27
|
-
Requires-Dist: asyncssh>=2.14; extra == 'all'
|
|
28
27
|
Requires-Dist: keyring>=24; extra == 'all'
|
|
29
28
|
Provides-Extra: bench
|
|
30
29
|
Requires-Dist: adlfs; extra == 'bench'
|
|
31
30
|
Requires-Dist: aiobotocore; extra == 'bench'
|
|
32
31
|
Requires-Dist: aiofiles; extra == 'bench'
|
|
32
|
+
Requires-Dist: asyncssh>=2.14; extra == 'bench'
|
|
33
33
|
Requires-Dist: azure-storage-blob; extra == 'bench'
|
|
34
34
|
Requires-Dist: cloudpathlib[all]; extra == 'bench'
|
|
35
35
|
Requires-Dist: fsspec; extra == 'bench'
|
|
@@ -43,7 +43,6 @@ Provides-Extra: credstore
|
|
|
43
43
|
Requires-Dist: keyring>=24; extra == 'credstore'
|
|
44
44
|
Provides-Extra: dev
|
|
45
45
|
Requires-Dist: aioftp>=0.21; extra == 'dev'
|
|
46
|
-
Requires-Dist: asyncssh>=2.14; extra == 'dev'
|
|
47
46
|
Requires-Dist: keyring>=24; extra == 'dev'
|
|
48
47
|
Requires-Dist: pre-commit>=4; extra == 'dev'
|
|
49
48
|
Requires-Dist: pytest-asyncio>=1.3; extra == 'dev'
|
|
@@ -52,8 +51,6 @@ Requires-Dist: pytest>=8; extra == 'dev'
|
|
|
52
51
|
Requires-Dist: rich-click>=1.9.7; extra == 'dev'
|
|
53
52
|
Provides-Extra: ftp
|
|
54
53
|
Requires-Dist: aioftp>=0.21; extra == 'ftp'
|
|
55
|
-
Provides-Extra: ssh
|
|
56
|
-
Requires-Dist: asyncssh>=2.14; extra == 'ssh'
|
|
57
54
|
Description-Content-Type: text/markdown
|
|
58
55
|
|
|
59
56
|
©2026 Axis Communications AB. AXIS COMMUNICATIONS, AXIS, ARTPEC and VAPIX are registered trademarks of Axis AB in various jurisdictions. All other trademarks are the property of their respective owners.
|
|
@@ -76,6 +73,9 @@ locally before connecting to a cloud account.
|
|
|
76
73
|
|
|
77
74
|
- Python 3.10 through 3.14.
|
|
78
75
|
- Access credentials for each cloud service that your application uses.
|
|
76
|
+
|
|
77
|
+
S3 authentication accepts explicit keys or uses the AWS SDK credential provider
|
|
78
|
+
chain (including shared profiles and IAM roles) when keys are not supplied.
|
|
79
79
|
- A POSIX-compatible shell such as Bash or Zsh for the terminal commands below.
|
|
80
80
|
Windows users can use Windows Subsystem for Linux or adapt the commands for
|
|
81
81
|
PowerShell.
|
|
@@ -182,8 +182,8 @@ uv sync
|
|
|
182
182
|
# With CLI
|
|
183
183
|
uv sync --extra cli
|
|
184
184
|
|
|
185
|
-
# With
|
|
186
|
-
uv sync --extra
|
|
185
|
+
# With FTP backend support (SSH/SFTP is built in — no extra needed)
|
|
186
|
+
uv sync --extra ftp
|
|
187
187
|
|
|
188
188
|
# With OS keyring-backed credential cache
|
|
189
189
|
uv sync --extra credstore
|
|
@@ -343,63 +343,66 @@ uv run --extra bench python scripts/bench.py --backend art --rounds 3 --concurre
|
|
|
343
343
|
|
|
344
344
|
| Operation | asanypath | s3fs | aiobotocore | cloudpathlib |
|
|
345
345
|
|-----------|-----------|------|-------------|--------------|
|
|
346
|
-
| write | 8.
|
|
347
|
-
| read |
|
|
348
|
-
| exists | 3.
|
|
349
|
-
| iterdir |
|
|
346
|
+
| write | **8.80 ms** | 12.67 ms | 28.07 ms | 41.75 ms |
|
|
347
|
+
| read | **3.78 ms** | 9.10 ms | 23.15 ms | 12.83 ms |
|
|
348
|
+
| exists | **3.57 ms** | 4.25 ms | 21.92 ms | 4.02 ms |
|
|
349
|
+
| iterdir | 40.55 ms | 39.16 ms | 56.49 ms | **39.07 ms** |
|
|
350
350
|
|
|
351
351
|
### Azure — Azurite emulator (median of 20 rounds, 10 objects, c=1)
|
|
352
352
|
|
|
353
353
|
| Operation | asanypath | azure-sdk | adlfs | cloudpathlib |
|
|
354
354
|
|-----------|-----------|-----------|-------|--------------|
|
|
355
|
-
| write | 1.
|
|
356
|
-
| read | 0.
|
|
357
|
-
| exists | 0.
|
|
358
|
-
| iterdir |
|
|
355
|
+
| write | **1.24 ms** | 1.79 ms | 2.34 ms | 7.88 ms |
|
|
356
|
+
| read | **0.51 ms** | 1.15 ms | 2.44 ms | 3.19 ms |
|
|
357
|
+
| exists | **0.35 ms** | 0.95 ms | 0.98 ms | 1.04 ms |
|
|
358
|
+
| iterdir | **0.88 ms** | 2.40 ms | 4.11 ms | 2.72 ms |
|
|
359
359
|
|
|
360
360
|
### GCS — fake-gcs-server emulator (median of 20 rounds, 10 objects, c=1)
|
|
361
361
|
|
|
362
362
|
| Operation | asanypath | gcloud-aio | cloudpathlib | gcsfs |
|
|
363
363
|
|-----------|-----------|------------|--------------|-------|
|
|
364
|
-
| write | 0.
|
|
365
|
-
| read | 0.
|
|
366
|
-
| exists | 0.
|
|
367
|
-
| iterdir | 0.
|
|
364
|
+
| write | **0.45 ms** | 0.51 ms | 3.53 ms | 0.58 ms |
|
|
365
|
+
| read | 0.22 ms | **0.18 ms** | 1.48 ms | 0.30 ms |
|
|
366
|
+
| exists | **0.15 ms** | 0.18 ms | 0.50 ms | 0.46 ms |
|
|
367
|
+
| iterdir | **0.19 ms** | 0.25 ms | 0.69 ms | 0.33 ms |
|
|
368
368
|
|
|
369
369
|
### Artifactory — live server (median of 20 rounds, 10 objects, c=1)
|
|
370
370
|
|
|
371
371
|
| Operation | asanypath | aiohttp | requests |
|
|
372
372
|
|-----------|-----------|---------|----------|
|
|
373
|
-
| write |
|
|
374
|
-
| read |
|
|
375
|
-
| exists | 2.
|
|
376
|
-
| iterdir |
|
|
373
|
+
| write | **54.49 ms** | 54.75 ms | 57.57 ms |
|
|
374
|
+
| read | **2.83 ms** | 3.09 ms | 12.01 ms |
|
|
375
|
+
| exists | 2.33 ms | **2.18 ms** | 4.61 ms |
|
|
376
|
+
| iterdir | **2.85 ms** | 3.07 ms | 7.88 ms |
|
|
377
377
|
|
|
378
378
|
### SSH / SFTP — localhost emulator (median of 20 rounds, 10 objects, c=1)
|
|
379
379
|
|
|
380
|
+
asanypath uses a built-in native russh SFTP transport (off-GIL); `asyncssh` is
|
|
381
|
+
shown only as a benchmark baseline.
|
|
382
|
+
|
|
380
383
|
| Operation | asanypath | asyncssh |
|
|
381
384
|
|-----------|-----------|----------|
|
|
382
|
-
| write | 0.
|
|
383
|
-
| read | 0.
|
|
384
|
-
| exists | 0.
|
|
385
|
-
| iterdir | 0.
|
|
385
|
+
| write | **0.16 ms** | 0.33 ms |
|
|
386
|
+
| read | **0.21 ms** | 0.36 ms |
|
|
387
|
+
| exists | **0.07 ms** | 0.11 ms |
|
|
388
|
+
| iterdir | **0.23 ms** | 0.44 ms |
|
|
386
389
|
|
|
387
390
|
### FTP — localhost emulator (median of 20 rounds, 10 objects, c=1)
|
|
388
391
|
|
|
389
392
|
| Operation | asanypath | aioftp |
|
|
390
393
|
|-----------|-----------|--------|
|
|
391
|
-
| write | 0.
|
|
392
|
-
| read | 0.
|
|
393
|
-
| exists | 1.
|
|
394
|
-
| iterdir | 1.
|
|
394
|
+
| write | **0.35 ms** | 0.41 ms |
|
|
395
|
+
| read | **0.35 ms** | 0.40 ms |
|
|
396
|
+
| exists | **1.00 ms** | 1.13 ms |
|
|
397
|
+
| iterdir | 1.09 ms | **1.04 ms** |
|
|
395
398
|
|
|
396
399
|
Latencies are per-operation medians over 20 rounds. S3 and Artifactory ran
|
|
397
400
|
against live services; Azure, GCS, SSH/SFTP, and FTP against local emulators
|
|
398
401
|
(Azurite, fake-gcs-server, and localhost sftp/ftp), so those figures reflect
|
|
399
|
-
client-library overhead, not WAN latency.
|
|
400
|
-
|
|
401
|
-
dedicated client libraries on most
|
|
402
|
-
API across all backends.
|
|
402
|
+
client-library overhead, not WAN latency. The SSH/SFTP transport is a built-in
|
|
403
|
+
native russh backend that runs SFTP off-GIL and beats asyncssh on every
|
|
404
|
+
operation. asanypath matches or beats dedicated client libraries on most
|
|
405
|
+
operations while providing a single unified API across all backends.
|
|
403
406
|
|
|
404
407
|
## How It Works
|
|
405
408
|
|
|
@@ -413,9 +416,17 @@ API across all backends.
|
|
|
413
416
|
| `az://`, `azure://` | `AzurePath` | reqwest + SharedKey |
|
|
414
417
|
| `art://` | `ArtifactoryPath` | reqwest + Bearer |
|
|
415
418
|
| `http://`, `https://` | `HTTPPath` / `HTTPSPath` | reqwest |
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
+
| `ssh://` | `SSHPath` | native russh (SFTP) |
|
|
420
|
+
| `ftp://` | `FTPPath` | `aioftp` |
|
|
421
|
+
| `ftps://` | `FTPSPath` | `aioftp` + TLS |
|
|
422
|
+
| any other scheme | `UnsupportedProtocolPath` | none — pure-path only |
|
|
423
|
+
|
|
424
|
+
Cloud and HTTP network I/O is handled by `asanypath-native` (Rust/PyO3 + reqwest)
|
|
425
|
+
with jittered exponential backoff retry for transient errors. SSH/SFTP uses a
|
|
426
|
+
built-in off-GIL native russh transport (no Python SSH dependency); FTP/FTPS use
|
|
427
|
+
`aioftp`. Unknown schemes construct an `UnsupportedProtocolPath`: pure-path
|
|
428
|
+
operations (`.name`, `.parent`, joins) work, while any backend operation raises
|
|
429
|
+
`UnsupportedProtocolError`.
|
|
419
430
|
|
|
420
431
|
## API Reference
|
|
421
432
|
|
|
@@ -18,6 +18,9 @@ locally before connecting to a cloud account.
|
|
|
18
18
|
|
|
19
19
|
- Python 3.10 through 3.14.
|
|
20
20
|
- Access credentials for each cloud service that your application uses.
|
|
21
|
+
|
|
22
|
+
S3 authentication accepts explicit keys or uses the AWS SDK credential provider
|
|
23
|
+
chain (including shared profiles and IAM roles) when keys are not supplied.
|
|
21
24
|
- A POSIX-compatible shell such as Bash or Zsh for the terminal commands below.
|
|
22
25
|
Windows users can use Windows Subsystem for Linux or adapt the commands for
|
|
23
26
|
PowerShell.
|
|
@@ -124,8 +127,8 @@ uv sync
|
|
|
124
127
|
# With CLI
|
|
125
128
|
uv sync --extra cli
|
|
126
129
|
|
|
127
|
-
# With
|
|
128
|
-
uv sync --extra
|
|
130
|
+
# With FTP backend support (SSH/SFTP is built in — no extra needed)
|
|
131
|
+
uv sync --extra ftp
|
|
129
132
|
|
|
130
133
|
# With OS keyring-backed credential cache
|
|
131
134
|
uv sync --extra credstore
|
|
@@ -285,63 +288,66 @@ uv run --extra bench python scripts/bench.py --backend art --rounds 3 --concurre
|
|
|
285
288
|
|
|
286
289
|
| Operation | asanypath | s3fs | aiobotocore | cloudpathlib |
|
|
287
290
|
|-----------|-----------|------|-------------|--------------|
|
|
288
|
-
| write | 8.
|
|
289
|
-
| read |
|
|
290
|
-
| exists | 3.
|
|
291
|
-
| iterdir |
|
|
291
|
+
| write | **8.80 ms** | 12.67 ms | 28.07 ms | 41.75 ms |
|
|
292
|
+
| read | **3.78 ms** | 9.10 ms | 23.15 ms | 12.83 ms |
|
|
293
|
+
| exists | **3.57 ms** | 4.25 ms | 21.92 ms | 4.02 ms |
|
|
294
|
+
| iterdir | 40.55 ms | 39.16 ms | 56.49 ms | **39.07 ms** |
|
|
292
295
|
|
|
293
296
|
### Azure — Azurite emulator (median of 20 rounds, 10 objects, c=1)
|
|
294
297
|
|
|
295
298
|
| Operation | asanypath | azure-sdk | adlfs | cloudpathlib |
|
|
296
299
|
|-----------|-----------|-----------|-------|--------------|
|
|
297
|
-
| write | 1.
|
|
298
|
-
| read | 0.
|
|
299
|
-
| exists | 0.
|
|
300
|
-
| iterdir |
|
|
300
|
+
| write | **1.24 ms** | 1.79 ms | 2.34 ms | 7.88 ms |
|
|
301
|
+
| read | **0.51 ms** | 1.15 ms | 2.44 ms | 3.19 ms |
|
|
302
|
+
| exists | **0.35 ms** | 0.95 ms | 0.98 ms | 1.04 ms |
|
|
303
|
+
| iterdir | **0.88 ms** | 2.40 ms | 4.11 ms | 2.72 ms |
|
|
301
304
|
|
|
302
305
|
### GCS — fake-gcs-server emulator (median of 20 rounds, 10 objects, c=1)
|
|
303
306
|
|
|
304
307
|
| Operation | asanypath | gcloud-aio | cloudpathlib | gcsfs |
|
|
305
308
|
|-----------|-----------|------------|--------------|-------|
|
|
306
|
-
| write | 0.
|
|
307
|
-
| read | 0.
|
|
308
|
-
| exists | 0.
|
|
309
|
-
| iterdir | 0.
|
|
309
|
+
| write | **0.45 ms** | 0.51 ms | 3.53 ms | 0.58 ms |
|
|
310
|
+
| read | 0.22 ms | **0.18 ms** | 1.48 ms | 0.30 ms |
|
|
311
|
+
| exists | **0.15 ms** | 0.18 ms | 0.50 ms | 0.46 ms |
|
|
312
|
+
| iterdir | **0.19 ms** | 0.25 ms | 0.69 ms | 0.33 ms |
|
|
310
313
|
|
|
311
314
|
### Artifactory — live server (median of 20 rounds, 10 objects, c=1)
|
|
312
315
|
|
|
313
316
|
| Operation | asanypath | aiohttp | requests |
|
|
314
317
|
|-----------|-----------|---------|----------|
|
|
315
|
-
| write |
|
|
316
|
-
| read |
|
|
317
|
-
| exists | 2.
|
|
318
|
-
| iterdir |
|
|
318
|
+
| write | **54.49 ms** | 54.75 ms | 57.57 ms |
|
|
319
|
+
| read | **2.83 ms** | 3.09 ms | 12.01 ms |
|
|
320
|
+
| exists | 2.33 ms | **2.18 ms** | 4.61 ms |
|
|
321
|
+
| iterdir | **2.85 ms** | 3.07 ms | 7.88 ms |
|
|
319
322
|
|
|
320
323
|
### SSH / SFTP — localhost emulator (median of 20 rounds, 10 objects, c=1)
|
|
321
324
|
|
|
325
|
+
asanypath uses a built-in native russh SFTP transport (off-GIL); `asyncssh` is
|
|
326
|
+
shown only as a benchmark baseline.
|
|
327
|
+
|
|
322
328
|
| Operation | asanypath | asyncssh |
|
|
323
329
|
|-----------|-----------|----------|
|
|
324
|
-
| write | 0.
|
|
325
|
-
| read | 0.
|
|
326
|
-
| exists | 0.
|
|
327
|
-
| iterdir | 0.
|
|
330
|
+
| write | **0.16 ms** | 0.33 ms |
|
|
331
|
+
| read | **0.21 ms** | 0.36 ms |
|
|
332
|
+
| exists | **0.07 ms** | 0.11 ms |
|
|
333
|
+
| iterdir | **0.23 ms** | 0.44 ms |
|
|
328
334
|
|
|
329
335
|
### FTP — localhost emulator (median of 20 rounds, 10 objects, c=1)
|
|
330
336
|
|
|
331
337
|
| Operation | asanypath | aioftp |
|
|
332
338
|
|-----------|-----------|--------|
|
|
333
|
-
| write | 0.
|
|
334
|
-
| read | 0.
|
|
335
|
-
| exists | 1.
|
|
336
|
-
| iterdir | 1.
|
|
339
|
+
| write | **0.35 ms** | 0.41 ms |
|
|
340
|
+
| read | **0.35 ms** | 0.40 ms |
|
|
341
|
+
| exists | **1.00 ms** | 1.13 ms |
|
|
342
|
+
| iterdir | 1.09 ms | **1.04 ms** |
|
|
337
343
|
|
|
338
344
|
Latencies are per-operation medians over 20 rounds. S3 and Artifactory ran
|
|
339
345
|
against live services; Azure, GCS, SSH/SFTP, and FTP against local emulators
|
|
340
346
|
(Azurite, fake-gcs-server, and localhost sftp/ftp), so those figures reflect
|
|
341
|
-
client-library overhead, not WAN latency.
|
|
342
|
-
|
|
343
|
-
dedicated client libraries on most
|
|
344
|
-
API across all backends.
|
|
347
|
+
client-library overhead, not WAN latency. The SSH/SFTP transport is a built-in
|
|
348
|
+
native russh backend that runs SFTP off-GIL and beats asyncssh on every
|
|
349
|
+
operation. asanypath matches or beats dedicated client libraries on most
|
|
350
|
+
operations while providing a single unified API across all backends.
|
|
345
351
|
|
|
346
352
|
## How It Works
|
|
347
353
|
|
|
@@ -355,9 +361,17 @@ API across all backends.
|
|
|
355
361
|
| `az://`, `azure://` | `AzurePath` | reqwest + SharedKey |
|
|
356
362
|
| `art://` | `ArtifactoryPath` | reqwest + Bearer |
|
|
357
363
|
| `http://`, `https://` | `HTTPPath` / `HTTPSPath` | reqwest |
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
364
|
+
| `ssh://` | `SSHPath` | native russh (SFTP) |
|
|
365
|
+
| `ftp://` | `FTPPath` | `aioftp` |
|
|
366
|
+
| `ftps://` | `FTPSPath` | `aioftp` + TLS |
|
|
367
|
+
| any other scheme | `UnsupportedProtocolPath` | none — pure-path only |
|
|
368
|
+
|
|
369
|
+
Cloud and HTTP network I/O is handled by `asanypath-native` (Rust/PyO3 + reqwest)
|
|
370
|
+
with jittered exponential backoff retry for transient errors. SSH/SFTP uses a
|
|
371
|
+
built-in off-GIL native russh transport (no Python SSH dependency); FTP/FTPS use
|
|
372
|
+
`aioftp`. Unknown schemes construct an `UnsupportedProtocolPath`: pure-path
|
|
373
|
+
operations (`.name`, `.parent`, joins) work, while any backend operation raises
|
|
374
|
+
`UnsupportedProtocolError`.
|
|
361
375
|
|
|
362
376
|
## API Reference
|
|
363
377
|
|
|
@@ -57,9 +57,9 @@ dev = [
|
|
|
57
57
|
]
|
|
58
58
|
cli = ["rich-click>=1.9.7"]
|
|
59
59
|
ftp = ["aioftp>=0.21"]
|
|
60
|
-
ssh = ["asyncssh>=2.14"]
|
|
61
60
|
credstore = ["keyring>=24"]
|
|
62
61
|
bench = [
|
|
62
|
+
"asyncssh>=2.14",
|
|
63
63
|
"fsspec",
|
|
64
64
|
"s3fs",
|
|
65
65
|
"adlfs",
|
|
@@ -71,7 +71,7 @@ bench = [
|
|
|
71
71
|
"aiofiles",
|
|
72
72
|
"requests",
|
|
73
73
|
]
|
|
74
|
-
all = ["asanypath[
|
|
74
|
+
all = ["asanypath[credstore,ftp]"]
|
|
75
75
|
[project.scripts]
|
|
76
76
|
asanypath = "asanypath.cli:main"
|
|
77
77
|
|
|
@@ -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.0
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 1, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -170,6 +170,18 @@ class ArtifactoryPath(CloudPathMixin):
|
|
|
170
170
|
def _item_path(self) -> str:
|
|
171
171
|
return self._repo_path
|
|
172
172
|
|
|
173
|
+
def _bind_path_attrs(self) -> None:
|
|
174
|
+
raw = str(self)[len("art://") :]
|
|
175
|
+
idx = raw.find("/artifactory/")
|
|
176
|
+
if idx >= 0:
|
|
177
|
+
self._base_url = raw[: idx + len("/artifactory")]
|
|
178
|
+
self._repo_path = raw[idx + len("/artifactory/") :]
|
|
179
|
+
else:
|
|
180
|
+
self._base_url = raw.split("/")[0]
|
|
181
|
+
self._repo_path = "/".join(raw.split("/")[1:])
|
|
182
|
+
self._is_folder = None
|
|
183
|
+
self._cached_info = None
|
|
184
|
+
|
|
173
185
|
@property
|
|
174
186
|
def _native_kwargs(self) -> dict:
|
|
175
187
|
return {
|
|
@@ -234,9 +246,7 @@ class ArtifactoryPath(CloudPathMixin):
|
|
|
234
246
|
containers = await self._list_containers()
|
|
235
247
|
if containers is not None:
|
|
236
248
|
for uri in containers:
|
|
237
|
-
|
|
238
|
-
child._token = self._token
|
|
239
|
-
yield child
|
|
249
|
+
yield self._child(uri)
|
|
240
250
|
return
|
|
241
251
|
batcher = self._get_batcher()
|
|
242
252
|
results = await batcher.list(
|
|
@@ -247,10 +257,7 @@ class ArtifactoryPath(CloudPathMixin):
|
|
|
247
257
|
base = str(self).rstrip("/")
|
|
248
258
|
for uri, is_folder in results:
|
|
249
259
|
child_name = uri.rsplit("/", 1)[-1]
|
|
250
|
-
|
|
251
|
-
child_path._token = self._token
|
|
252
|
-
child_path._is_folder = is_folder
|
|
253
|
-
yield child_path
|
|
260
|
+
yield self._child(f"{base}/{child_name}", _is_folder=is_folder)
|
|
254
261
|
|
|
255
262
|
async def _list_containers(self) -> list[str] | None:
|
|
256
263
|
"""List repositories when at the server root (``art://<host>/artifactory/``).
|
|
@@ -162,6 +162,10 @@ class AzurePath(CloudPathMixin):
|
|
|
162
162
|
def _item_path(self) -> str:
|
|
163
163
|
return self._blob_path
|
|
164
164
|
|
|
165
|
+
def _bind_path_attrs(self) -> None:
|
|
166
|
+
self._container = self._path.host or ""
|
|
167
|
+
self._blob_path = self._path.path.lstrip("/")
|
|
168
|
+
|
|
165
169
|
@property
|
|
166
170
|
def _native_kwargs(self) -> dict:
|
|
167
171
|
return {
|
|
@@ -504,7 +504,7 @@ def _maybe_rewrite_scp_style(value: str, check_config: bool = True) -> str:
|
|
|
504
504
|
return value
|
|
505
505
|
if not _discover_ssh_config():
|
|
506
506
|
return value
|
|
507
|
-
resolved_host, _, _ = _resolve_alias(host, None, None)
|
|
507
|
+
resolved_host, _, _, _ = _resolve_alias(host, None, None)
|
|
508
508
|
if resolved_host == host:
|
|
509
509
|
# No Host block matched — leave alone (don't probe DNS).
|
|
510
510
|
return value
|
|
@@ -23,6 +23,8 @@ if TYPE_CHECKING:
|
|
|
23
23
|
|
|
24
24
|
from os import linesep
|
|
25
25
|
|
|
26
|
+
from yarl import URL
|
|
27
|
+
|
|
26
28
|
from asanypath._transfer import (
|
|
27
29
|
async_destination_state,
|
|
28
30
|
has_async_transfer_api,
|
|
@@ -108,7 +110,7 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
108
110
|
else None
|
|
109
111
|
),
|
|
110
112
|
"use_h2": False,
|
|
111
|
-
**self.
|
|
113
|
+
**(await self._get_native_kwargs()),
|
|
112
114
|
}
|
|
113
115
|
await type(self)._copy_batch_fn(**kwargs)
|
|
114
116
|
if not want_size:
|
|
@@ -134,11 +136,47 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
134
136
|
"""The backend-specific object / blob / key path. Override in subclasses."""
|
|
135
137
|
raise NotImplementedError(f"{type(self).__name__} must define _item_path")
|
|
136
138
|
|
|
139
|
+
def _spawn(self, uri: str) -> Self:
|
|
140
|
+
"""Build a sibling path from a canonical URI, inheriting this path's
|
|
141
|
+
credentials and config without re-parsing the scheme or re-reading env.
|
|
142
|
+
|
|
143
|
+
Used by ``iterdir``/``glob``/``walk`` where many children share the
|
|
144
|
+
parent's connection settings and differ only in their object key.
|
|
145
|
+
"""
|
|
146
|
+
obj = object.__new__(type(self))
|
|
147
|
+
obj.__dict__.update(self.__dict__)
|
|
148
|
+
obj._path = URL(uri, encoded="?" in uri)
|
|
149
|
+
obj._bind_path_attrs()
|
|
150
|
+
return obj
|
|
151
|
+
|
|
152
|
+
def _bind_path_attrs(self) -> None:
|
|
153
|
+
"""Recompute path-derived attributes after ``_path`` is reassigned.
|
|
154
|
+
|
|
155
|
+
Subclasses that store path-derived attributes override this.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def _child(self, uri: str, **attrs: object) -> Self:
|
|
159
|
+
"""Spawn an ``iterdir``/listing child from a canonical URI and attach
|
|
160
|
+
attributes already known from the listing (cached stat, folder hint,
|
|
161
|
+
trailing-slash hint) so the child needn't re-fetch them.
|
|
162
|
+
|
|
163
|
+
One fast path for every backend: no per-child ``__init__`` (scheme
|
|
164
|
+
re-parse, env read, ssh_config resolution) and no re-stat.
|
|
165
|
+
"""
|
|
166
|
+
child = self._spawn(uri)
|
|
167
|
+
for name, value in attrs.items():
|
|
168
|
+
setattr(child, name, value)
|
|
169
|
+
return child
|
|
170
|
+
|
|
137
171
|
@property
|
|
138
172
|
def _native_kwargs(self) -> dict:
|
|
139
173
|
"""Auth / endpoint kwargs forwarded to batcher and native calls."""
|
|
140
174
|
raise NotImplementedError(f"{type(self).__name__} must define _native_kwargs")
|
|
141
175
|
|
|
176
|
+
async def _get_native_kwargs(self) -> dict:
|
|
177
|
+
"""Resolve request credentials asynchronously when a backend needs it."""
|
|
178
|
+
return self._native_kwargs
|
|
179
|
+
|
|
142
180
|
async def is_dir(self) -> bool:
|
|
143
181
|
"""Check if path is a directory prefix. Override in subclasses."""
|
|
144
182
|
raise NotImplementedError(f"{type(self).__name__} must define is_dir()")
|
|
@@ -203,9 +241,10 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
203
241
|
|
|
204
242
|
async def exists(self) -> bool:
|
|
205
243
|
"""Check if object exists."""
|
|
244
|
+
native_kwargs = await self._get_native_kwargs()
|
|
206
245
|
return await self._get_batcher().exists(
|
|
207
246
|
item=self._item_path,
|
|
208
|
-
**
|
|
247
|
+
**native_kwargs,
|
|
209
248
|
)
|
|
210
249
|
|
|
211
250
|
async def is_file(self) -> bool:
|
|
@@ -216,9 +255,10 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
216
255
|
|
|
217
256
|
async def read_bytes(self) -> bytes:
|
|
218
257
|
"""Read object content as bytes."""
|
|
258
|
+
native_kwargs = await self._get_native_kwargs()
|
|
219
259
|
return await self._get_batcher().get(
|
|
220
260
|
item=self._item_path,
|
|
221
|
-
**
|
|
261
|
+
**native_kwargs,
|
|
222
262
|
)
|
|
223
263
|
|
|
224
264
|
async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
|
|
@@ -249,7 +289,12 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
249
289
|
from asanypath_native import range_read
|
|
250
290
|
|
|
251
291
|
return bytes(
|
|
252
|
-
await range_read(
|
|
292
|
+
await range_read(
|
|
293
|
+
path=self._item_path,
|
|
294
|
+
start=start,
|
|
295
|
+
end=end,
|
|
296
|
+
**(await self._get_native_kwargs()),
|
|
297
|
+
)
|
|
253
298
|
)
|
|
254
299
|
|
|
255
300
|
async def read_text(
|
|
@@ -275,13 +320,14 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
275
320
|
options_json = (
|
|
276
321
|
msgspec.json.encode(backend_options).decode() if backend_options is not None else "{}"
|
|
277
322
|
)
|
|
323
|
+
native_kwargs = await self._get_native_kwargs()
|
|
278
324
|
await self._get_batcher().put(
|
|
279
325
|
item=(
|
|
280
326
|
self._item_path,
|
|
281
327
|
data,
|
|
282
328
|
options_json,
|
|
283
329
|
),
|
|
284
|
-
**
|
|
330
|
+
**native_kwargs,
|
|
285
331
|
)
|
|
286
332
|
return len(data)
|
|
287
333
|
|
|
@@ -307,9 +353,10 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
307
353
|
async def unlink(self, missing_ok: bool = False) -> None:
|
|
308
354
|
"""Delete object."""
|
|
309
355
|
try:
|
|
356
|
+
native_kwargs = await self._get_native_kwargs()
|
|
310
357
|
await self._get_batcher().delete(
|
|
311
358
|
item=self._item_path,
|
|
312
|
-
**
|
|
359
|
+
**native_kwargs,
|
|
313
360
|
)
|
|
314
361
|
except FileNotFoundError: # pragma: no cover
|
|
315
362
|
if not missing_ok: # pragma: no cover
|
|
@@ -321,9 +368,10 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
321
368
|
if containers is not None:
|
|
322
369
|
return containers
|
|
323
370
|
try:
|
|
371
|
+
native_kwargs = await self._get_native_kwargs()
|
|
324
372
|
return await self._get_batcher().list(
|
|
325
373
|
item=self._item_path,
|
|
326
|
-
**
|
|
374
|
+
**native_kwargs,
|
|
327
375
|
)
|
|
328
376
|
except FileNotFoundError:
|
|
329
377
|
raise NotADirectoryError(20, f"Not a directory: '{self}'")
|
|
@@ -362,7 +410,7 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
362
410
|
ts, uris = entry
|
|
363
411
|
if (time.monotonic() - ts) < self.listing_cache_ttl:
|
|
364
412
|
for uri in uris:
|
|
365
|
-
yield
|
|
413
|
+
yield self._child(uri)
|
|
366
414
|
return
|
|
367
415
|
|
|
368
416
|
uris = await self._fetch_listing()
|
|
@@ -371,7 +419,7 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
371
419
|
cache[cache_key] = (time.monotonic(), uris)
|
|
372
420
|
|
|
373
421
|
for uri in uris:
|
|
374
|
-
yield
|
|
422
|
+
yield self._child(uri)
|
|
375
423
|
|
|
376
424
|
def chmod(self, mode: int, *, follow_symlinks: bool = True) -> None:
|
|
377
425
|
raise NotImplementedError(f"{type(self).__name__} does not support chmod")
|
|
@@ -803,7 +851,7 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
803
851
|
is_dir_results = await batch_fn(
|
|
804
852
|
prefixes=prefixes,
|
|
805
853
|
use_h2=len(prefixes) >= H2_BATCH_THRESHOLD,
|
|
806
|
-
**self.
|
|
854
|
+
**(await self._get_native_kwargs()),
|
|
807
855
|
)
|
|
808
856
|
elif entries:
|
|
809
857
|
import asyncio
|
|
@@ -976,10 +1024,9 @@ class _CloudFile:
|
|
|
976
1024
|
if "r" in self._mode:
|
|
977
1025
|
if self._path._supports_range_read:
|
|
978
1026
|
try:
|
|
1027
|
+
native_kwargs = runner.run(self._path._get_native_kwargs())
|
|
979
1028
|
headers = runner.run(
|
|
980
|
-
self._path._get_batcher().head(
|
|
981
|
-
item=self._path._item_path, **self._path._native_kwargs
|
|
982
|
-
)
|
|
1029
|
+
self._path._get_batcher().head(item=self._path._item_path, **native_kwargs)
|
|
983
1030
|
)
|
|
984
1031
|
size = self._get_size(headers)
|
|
985
1032
|
if size is not None and size > self._chunk_size:
|
|
@@ -1016,8 +1063,9 @@ class _CloudFile:
|
|
|
1016
1063
|
if "r" in self._mode:
|
|
1017
1064
|
if self._path._supports_range_read:
|
|
1018
1065
|
try:
|
|
1066
|
+
native_kwargs = await self._path._get_native_kwargs()
|
|
1019
1067
|
headers = await self._path._get_batcher().head(
|
|
1020
|
-
item=self._path._item_path, **
|
|
1068
|
+
item=self._path._item_path, **native_kwargs
|
|
1021
1069
|
)
|
|
1022
1070
|
size = self._get_size(headers)
|
|
1023
1071
|
if size is not None and size > self._chunk_size:
|
|
@@ -485,7 +485,7 @@ class FTPPath(CloudPathMixin):
|
|
|
485
485
|
except aioftp.StatusCodeError as e:
|
|
486
486
|
raise NotADirectoryError(20, f"Not a directory: '{self}'") from e
|
|
487
487
|
for entry_path, _info in entries:
|
|
488
|
-
yield self / entry_path.name
|
|
488
|
+
yield self._child(str(self._path / entry_path.name))
|
|
489
489
|
|
|
490
490
|
async def walk(
|
|
491
491
|
self,
|
|
@@ -129,6 +129,10 @@ class GCSPath(CloudPathMixin):
|
|
|
129
129
|
def _item_path(self) -> str:
|
|
130
130
|
return self._object_path
|
|
131
131
|
|
|
132
|
+
def _bind_path_attrs(self) -> None:
|
|
133
|
+
self._bucket = self._path.host or ""
|
|
134
|
+
self._object_path = self._path.path.lstrip("/")
|
|
135
|
+
|
|
132
136
|
@property
|
|
133
137
|
def _native_kwargs(self) -> dict:
|
|
134
138
|
return {
|