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.
Files changed (69) hide show
  1. {asanypath-2.0.1 → asanypath-2.1.0}/.github/workflows/publish.yml +2 -2
  2. {asanypath-2.0.1 → asanypath-2.1.0}/.github/workflows/unittests.yml +1 -1
  3. {asanypath-2.0.1 → asanypath-2.1.0}/PKG-INFO +50 -39
  4. {asanypath-2.0.1 → asanypath-2.1.0}/README.md +47 -33
  5. {asanypath-2.0.1 → asanypath-2.1.0}/pyproject.toml +2 -2
  6. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/_version.py +2 -2
  7. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/artifactory.py +14 -7
  8. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/azure.py +4 -0
  9. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/cli.py +1 -1
  10. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/cloud.py +62 -14
  11. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/ftp.py +1 -1
  12. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/gcs.py +4 -0
  13. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/http.py +2 -5
  14. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/s3.py +76 -62
  15. asanypath-2.1.0/src/asanypath/ssh.py +594 -0
  16. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath_native.pyi +26 -0
  17. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_cli.py +3 -15
  18. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_s3.py +80 -54
  19. asanypath-2.1.0/tests/test_ssh.py +560 -0
  20. {asanypath-2.0.1 → asanypath-2.1.0}/uv.lock +15 -21
  21. asanypath-2.0.1/src/asanypath/ssh.py +0 -776
  22. asanypath-2.0.1/tests/test_ssh.py +0 -995
  23. {asanypath-2.0.1 → asanypath-2.1.0}/.copilot/skills/async-python-lib/SKILL.md +0 -0
  24. {asanypath-2.0.1 → asanypath-2.1.0}/.copilot/skills/gitguro/SKILL.md +0 -0
  25. {asanypath-2.0.1 → asanypath-2.1.0}/.github/CODEOWNERS +0 -0
  26. {asanypath-2.0.1 → asanypath-2.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  27. {asanypath-2.0.1 → asanypath-2.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  28. {asanypath-2.0.1 → asanypath-2.1.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  29. {asanypath-2.0.1 → asanypath-2.1.0}/.github/dependabot.yml +0 -0
  30. {asanypath-2.0.1 → asanypath-2.1.0}/.gitignore +0 -0
  31. {asanypath-2.0.1 → asanypath-2.1.0}/.pre-commit-config.yaml +0 -0
  32. {asanypath-2.0.1 → asanypath-2.1.0}/CODE_OF_CONDUCT.md +0 -0
  33. {asanypath-2.0.1 → asanypath-2.1.0}/CONTRIBUTING.md +0 -0
  34. {asanypath-2.0.1 → asanypath-2.1.0}/LICENSE +0 -0
  35. {asanypath-2.0.1 → asanypath-2.1.0}/SECURITY.md +0 -0
  36. {asanypath-2.0.1 → asanypath-2.1.0}/asanypath.png +0 -0
  37. {asanypath-2.0.1 → asanypath-2.1.0}/compose.yml +0 -0
  38. {asanypath-2.0.1 → asanypath-2.1.0}/hatch_build.py +0 -0
  39. {asanypath-2.0.1 → asanypath-2.1.0}/scripts/bench.py +0 -0
  40. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/__init__.py +0 -0
  41. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/__init__.pyi +0 -0
  42. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/_credstore.py +0 -0
  43. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/_transfer.py +0 -0
  44. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/asanypath.py +0 -0
  45. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/asanypath.pyi +0 -0
  46. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/batcher.py +0 -0
  47. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/common.py +0 -0
  48. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/exceptions.py +0 -0
  49. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/local.py +0 -0
  50. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/options.py +0 -0
  51. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/sync.py +0 -0
  52. {asanypath-2.0.1 → asanypath-2.1.0}/src/asanypath/unsupported.py +0 -0
  53. {asanypath-2.0.1 → asanypath-2.1.0}/tests/__init__.py +0 -0
  54. {asanypath-2.0.1 → asanypath-2.1.0}/tests/conftest.py +0 -0
  55. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_artifactory.py +0 -0
  56. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_asanypath.py +0 -0
  57. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_azure.py +0 -0
  58. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_batcher.py +0 -0
  59. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_cloudpathmixin.py +0 -0
  60. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_commonpathmixin.py +0 -0
  61. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_credstore.py +0 -0
  62. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_ftp.py +0 -0
  63. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_gcs.py +0 -0
  64. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_http.py +0 -0
  65. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_http_auth.py +0 -0
  66. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_local.py +0 -0
  67. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_sync.py +0 -0
  68. {asanypath-2.0.1 → asanypath-2.1.0}/tests/test_syncpath.py +0 -0
  69. {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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
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.1
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.0.0
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 SSH, FTP backend support
186
- uv sync --extra ssh --extra ftp
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.47 ms | 9.52 ms | 43.39 ms | 48.02 ms |
347
- | read | 4.03 ms | 9.43 ms | 20.16 ms | 13.30 ms |
348
- | exists | 3.58 ms | 4.54 ms | 20.49 ms | 4.48 ms |
349
- | iterdir | 38.91 ms | 39.33 ms | 57.76 ms | 41.95 ms |
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.77 ms | 1.86 ms | 1.93 ms | 8.29 ms |
356
- | read | 0.68 ms | 1.15 ms | 2.50 ms | 3.46 ms |
357
- | exists | 0.56 ms | 0.83 ms | 1.04 ms | 1.02 ms |
358
- | iterdir | 1.34 ms | 2.21 ms | 4.37 ms | 2.59 ms |
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.22 ms | 0.24 ms | 2.88 ms | 0.33 ms |
365
- | read | 0.12 ms | 0.14 ms | 1.34 ms | 0.19 ms |
366
- | exists | 0.12 ms | 0.14 ms | 0.44 ms | 0.41 ms |
367
- | iterdir | 0.20 ms | 0.16 ms | 0.55 ms | 0.24 ms |
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 | 39.77 ms | 38.00 ms | 36.67 ms |
374
- | read | 4.29 ms | 4.75 ms | 5.43 ms |
375
- | exists | 2.97 ms | 3.02 ms | 4.52 ms |
376
- | iterdir | 4.59 ms | 4.18 ms | 6.26 ms |
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.32 ms | 0.27 ms |
383
- | read | 0.34 ms | 0.33 ms |
384
- | exists | 0.11 ms | 0.11 ms |
385
- | iterdir | 0.52 ms | 0.40 ms |
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.40 ms | 0.34 ms |
392
- | read | 0.36 ms | 0.32 ms |
393
- | exists | 1.12 ms | 0.92 ms |
394
- | iterdir | 1.10 ms | 0.92 ms |
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. (A direct live-remote SSH run confirms
400
- the tie — asanypath vs asyncssh within noise.) asanypath matches or beats
401
- dedicated client libraries on most operations while providing a single unified
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
- All network I/O is handled by `asanypath-native` (Rust/PyO3 + reqwest) with
418
- jittered exponential backoff retry for transient errors.
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 SSH, FTP backend support
128
- uv sync --extra ssh --extra ftp
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.47 ms | 9.52 ms | 43.39 ms | 48.02 ms |
289
- | read | 4.03 ms | 9.43 ms | 20.16 ms | 13.30 ms |
290
- | exists | 3.58 ms | 4.54 ms | 20.49 ms | 4.48 ms |
291
- | iterdir | 38.91 ms | 39.33 ms | 57.76 ms | 41.95 ms |
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.77 ms | 1.86 ms | 1.93 ms | 8.29 ms |
298
- | read | 0.68 ms | 1.15 ms | 2.50 ms | 3.46 ms |
299
- | exists | 0.56 ms | 0.83 ms | 1.04 ms | 1.02 ms |
300
- | iterdir | 1.34 ms | 2.21 ms | 4.37 ms | 2.59 ms |
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.22 ms | 0.24 ms | 2.88 ms | 0.33 ms |
307
- | read | 0.12 ms | 0.14 ms | 1.34 ms | 0.19 ms |
308
- | exists | 0.12 ms | 0.14 ms | 0.44 ms | 0.41 ms |
309
- | iterdir | 0.20 ms | 0.16 ms | 0.55 ms | 0.24 ms |
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 | 39.77 ms | 38.00 ms | 36.67 ms |
316
- | read | 4.29 ms | 4.75 ms | 5.43 ms |
317
- | exists | 2.97 ms | 3.02 ms | 4.52 ms |
318
- | iterdir | 4.59 ms | 4.18 ms | 6.26 ms |
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.32 ms | 0.27 ms |
325
- | read | 0.34 ms | 0.33 ms |
326
- | exists | 0.11 ms | 0.11 ms |
327
- | iterdir | 0.52 ms | 0.40 ms |
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.40 ms | 0.34 ms |
334
- | read | 0.36 ms | 0.32 ms |
335
- | exists | 1.12 ms | 0.92 ms |
336
- | iterdir | 1.10 ms | 0.92 ms |
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. (A direct live-remote SSH run confirms
342
- the tie — asanypath vs asyncssh within noise.) asanypath matches or beats
343
- dedicated client libraries on most operations while providing a single unified
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
- All network I/O is handled by `asanypath-native` (Rust/PyO3 + reqwest) with
360
- jittered exponential backoff retry for transient errors.
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[ssh,credstore,ftp]"]
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.1'
22
- __version_tuple__ = version_tuple = (2, 0, 1)
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
- child = type(self)(uri)
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
- child_path = type(self)(f"{base}/{child_name}")
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._native_kwargs,
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
- **self._native_kwargs,
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
- **self._native_kwargs,
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(path=self._item_path, start=start, end=end, **self._native_kwargs)
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
- **self._native_kwargs,
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
- **self._native_kwargs,
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
- **self._native_kwargs,
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 type(self)(uri)
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 type(self)(uri)
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._native_kwargs,
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, **self._path._native_kwargs
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 {