asanypath 2.1.0__tar.gz → 2.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {asanypath-2.1.0 → asanypath-2.1.1}/.github/workflows/publish.yml +2 -2
- {asanypath-2.1.0 → asanypath-2.1.1}/.github/workflows/unittests.yml +1 -1
- {asanypath-2.1.0 → asanypath-2.1.1}/.gitignore +1 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/PKG-INFO +2 -2
- {asanypath-2.1.0 → asanypath-2.1.1}/hatch_build.py +1 -1
- {asanypath-2.1.0 → asanypath-2.1.1}/pyproject.toml +4 -1
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/_transfer.py +13 -5
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/_version.py +2 -2
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/artifactory.py +3 -2
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/asanypath.py +1 -1
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/asanypath.pyi +1 -1
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/cli.py +2 -2
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/cloud.py +28 -13
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/http.py +23 -15
- asanypath-2.1.1/src/asanypath/py.typed +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/ssh.py +90 -34
- asanypath-2.1.1/src/asanypath/unsupported.py +64 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_cli.py +2 -2
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_cloudpathmixin.py +2 -2
- asanypath-2.1.1/tests/test_py_typed.py +14 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_ssh.py +14 -0
- asanypath-2.1.1/uv.lock +2831 -0
- asanypath-2.1.0/src/asanypath/unsupported.py +0 -202
- asanypath-2.1.0/src/asanypath_native.pyi +0 -123
- asanypath-2.1.0/uv.lock +0 -2798
- {asanypath-2.1.0 → asanypath-2.1.1}/.copilot/skills/async-python-lib/SKILL.md +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/.copilot/skills/gitguro/SKILL.md +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/.github/CODEOWNERS +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/.github/dependabot.yml +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/.pre-commit-config.yaml +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/CODE_OF_CONDUCT.md +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/CONTRIBUTING.md +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/LICENSE +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/README.md +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/SECURITY.md +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/asanypath.png +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/compose.yml +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/scripts/bench.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/__init__.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/__init__.pyi +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/_credstore.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/azure.py +3 -3
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/batcher.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/common.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/exceptions.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/ftp.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/gcs.py +3 -3
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/local.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/options.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/s3.py +10 -10
- {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/sync.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/__init__.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/conftest.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_artifactory.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_asanypath.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_azure.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_batcher.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_commonpathmixin.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_credstore.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_ftp.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_gcs.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_http.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_http_auth.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_local.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_s3.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_sync.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_syncpath.py +0 -0
- {asanypath-2.1.0 → asanypath-2.1.1}/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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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.1.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: Async pathlib for local and cloud storage
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Requires-Dist: anyio>=4
|
|
22
22
|
Requires-Dist: asanypath-native~=2.1.0
|
|
23
|
-
Requires-Dist: msgspec>=0.
|
|
23
|
+
Requires-Dist: msgspec>=0.22.0
|
|
24
24
|
Requires-Dist: yarl>=1.2
|
|
25
25
|
Provides-Extra: all
|
|
26
26
|
Requires-Dist: aioftp>=0.21; extra == 'all'
|
|
@@ -88,6 +88,9 @@ python_version = "3.10"
|
|
|
88
88
|
warn_return_any = true
|
|
89
89
|
warn_unused_configs = true
|
|
90
90
|
disallow_untyped_defs = true
|
|
91
|
+
# asanypath_native.pyi lives in rust/ (its canonical home, shipped in the native
|
|
92
|
+
# wheel by maturin); keep it resolvable for dev type-checking.
|
|
93
|
+
mypy_path = ["src", "rust"]
|
|
91
94
|
|
|
92
95
|
[[tool.mypy.overrides]]
|
|
93
96
|
module = "asanypath.cli"
|
|
@@ -111,7 +114,7 @@ disable_error_code = ["arg-type", "assignment", "misc", "attr-defined"]
|
|
|
111
114
|
module = "asanypath.http"
|
|
112
115
|
disallow_untyped_defs = false
|
|
113
116
|
warn_return_any = false
|
|
114
|
-
disable_error_code = ["override", "type-arg", "return-value", "assignment"]
|
|
117
|
+
disable_error_code = ["override", "type-arg", "return-value", "assignment", "attr-defined"]
|
|
115
118
|
|
|
116
119
|
[[tool.mypy.overrides]]
|
|
117
120
|
module = ["asanypath.s3", "asanypath.gcs", "asanypath.azure", "asanypath.artifactory"]
|
|
@@ -15,6 +15,8 @@ from hashlib import sha256
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
|
+
from asanypath.exceptions import UnsupportedProtocolError
|
|
19
|
+
|
|
18
20
|
_CHECKSUM_PREFERENCE = ("sha256", "sha1", "md5")
|
|
19
21
|
|
|
20
22
|
# Default streaming chunk size for chunk_size=None ("auto").
|
|
@@ -79,19 +81,25 @@ def _normalized_checksums(checksums: dict[str, str]) -> dict[str, str]:
|
|
|
79
81
|
def is_remote_destination(dst: Any) -> bool:
|
|
80
82
|
protocol = getattr(dst, "protocol", None)
|
|
81
83
|
if protocol is not None:
|
|
82
|
-
return protocol != "file"
|
|
84
|
+
return bool(protocol != "file")
|
|
83
85
|
value = str(dst)
|
|
84
86
|
return "://" in value and not value.startswith("file:")
|
|
85
87
|
|
|
86
88
|
|
|
87
89
|
def has_async_transfer_api(dst: Any) -> bool:
|
|
88
|
-
|
|
89
|
-
getattr(dst, "exists", None)
|
|
90
|
-
|
|
90
|
+
try:
|
|
91
|
+
exists = getattr(dst, "exists", None)
|
|
92
|
+
write_bytes = getattr(dst, "write_bytes", None)
|
|
93
|
+
except UnsupportedProtocolError:
|
|
94
|
+
return False
|
|
95
|
+
return inspect.iscoroutinefunction(exists) and inspect.iscoroutinefunction(write_bytes)
|
|
91
96
|
|
|
92
97
|
|
|
93
98
|
def has_write_api(dst: Any) -> bool:
|
|
94
|
-
|
|
99
|
+
try:
|
|
100
|
+
return callable(getattr(dst, "write_bytes", None))
|
|
101
|
+
except UnsupportedProtocolError:
|
|
102
|
+
return False
|
|
95
103
|
|
|
96
104
|
|
|
97
105
|
def run_sync_maybe(value: Any) -> Any:
|
|
@@ -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.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 1,
|
|
21
|
+
__version__ = version = '2.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 1, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -21,8 +21,6 @@ import msgspec
|
|
|
21
21
|
if TYPE_CHECKING:
|
|
22
22
|
from typing import Self
|
|
23
23
|
|
|
24
|
-
from asanypath.cloud import CloudPathMixin
|
|
25
|
-
from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
|
|
26
24
|
from asanypath_native import (
|
|
27
25
|
art_copy_batch,
|
|
28
26
|
art_delete_batch,
|
|
@@ -36,6 +34,9 @@ from asanypath_native import (
|
|
|
36
34
|
art_storage_info,
|
|
37
35
|
)
|
|
38
36
|
|
|
37
|
+
from asanypath.cloud import CloudPathMixin
|
|
38
|
+
from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
|
|
39
|
+
|
|
39
40
|
|
|
40
41
|
def _jfrog_conf_token(base_url: str) -> str | None:
|
|
41
42
|
"""Return an access token from the JFrog CLI config matching ``base_url``.
|
|
@@ -185,7 +185,7 @@ class AsAnyPath(AsAnyPurePath):
|
|
|
185
185
|
async def rglob(
|
|
186
186
|
self, pattern: str, *, case_sensitive: bool | None = None
|
|
187
187
|
) -> AsyncIterator[Self]: ...
|
|
188
|
-
async def rmdir(self) -> None: ...
|
|
188
|
+
async def rmdir(self, *, recursive: bool = False) -> None: ...
|
|
189
189
|
def samefile(self, other_path: str | PathLike[str]) -> bool: ...
|
|
190
190
|
async def stat(self, *, follow_symlinks: bool = True) -> stat_result: ...
|
|
191
191
|
def symlink_to(
|
|
@@ -1383,8 +1383,8 @@ def ls(
|
|
|
1383
1383
|
found_any = bool(rows)
|
|
1384
1384
|
|
|
1385
1385
|
if not found_any:
|
|
1386
|
-
|
|
1387
|
-
return
|
|
1386
|
+
# No results is a non-match: exit non-zero like grep/aws s3 ls.
|
|
1387
|
+
return 1
|
|
1388
1388
|
|
|
1389
1389
|
if simple:
|
|
1390
1390
|
for row in rows:
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import asyncio
|
|
9
10
|
import fnmatch
|
|
10
11
|
import inspect
|
|
11
12
|
import io
|
|
@@ -329,6 +330,7 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
329
330
|
),
|
|
330
331
|
**native_kwargs,
|
|
331
332
|
)
|
|
333
|
+
type(self)._listing_cache.pop(self._listing_cache_key(), None)
|
|
332
334
|
return len(data)
|
|
333
335
|
|
|
334
336
|
async def write_text(
|
|
@@ -358,6 +360,7 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
358
360
|
item=self._item_path,
|
|
359
361
|
**native_kwargs,
|
|
360
362
|
)
|
|
363
|
+
type(self)._listing_cache.pop(self._listing_cache_key(), None)
|
|
361
364
|
except FileNotFoundError: # pragma: no cover
|
|
362
365
|
if not missing_ok: # pragma: no cover
|
|
363
366
|
raise
|
|
@@ -610,19 +613,31 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
610
613
|
Without it, only succeeds if the prefix is empty (no children).
|
|
611
614
|
"""
|
|
612
615
|
if not recursive:
|
|
613
|
-
|
|
614
|
-
if children:
|
|
616
|
+
async for _ in self.iterdir(fresh=True):
|
|
615
617
|
raise OSError(39, f"Directory not empty: '{self}'")
|
|
616
618
|
return
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
619
|
+
await self._rmtree()
|
|
620
|
+
|
|
621
|
+
async def _rmtree(self) -> None:
|
|
622
|
+
"""Recursively delete this prefix's contents with concurrency.
|
|
623
|
+
|
|
624
|
+
Children are deleted concurrently so the :class:`MicroBatcher` can
|
|
625
|
+
coalesce the in-flight object deletes into batch API calls; no
|
|
626
|
+
Python-side semaphore is used because a small bound would only cap
|
|
627
|
+
the batch size and defeat that coalescing.
|
|
628
|
+
"""
|
|
629
|
+
|
|
630
|
+
async def _remove(child: Self) -> None:
|
|
620
631
|
if await child.is_dir():
|
|
621
|
-
await child.
|
|
632
|
+
await child._rmtree()
|
|
622
633
|
else:
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
634
|
+
await child.unlink()
|
|
635
|
+
|
|
636
|
+
children = [_remove(child) async for child in self.iterdir(fresh=True)]
|
|
637
|
+
for result in await asyncio.gather(*children, return_exceptions=True):
|
|
638
|
+
if isinstance(result, BaseException):
|
|
639
|
+
raise result
|
|
640
|
+
type(self)._listing_cache.pop(self._listing_cache_key(), None)
|
|
626
641
|
|
|
627
642
|
async def copy(
|
|
628
643
|
self,
|
|
@@ -854,13 +869,13 @@ class CloudPathMixin(CommonPurePathMixin):
|
|
|
854
869
|
**(await self._get_native_kwargs()),
|
|
855
870
|
)
|
|
856
871
|
elif entries:
|
|
857
|
-
import asyncio
|
|
858
|
-
|
|
859
872
|
is_dir_results = await asyncio.gather(*(e.is_dir() for e in entries))
|
|
860
873
|
else:
|
|
861
874
|
is_dir_results = []
|
|
862
|
-
dirs = [
|
|
863
|
-
files = [
|
|
875
|
+
dirs: list[Self] = []
|
|
876
|
+
files: list[Self] = []
|
|
877
|
+
for entry, is_directory in zip(entries, is_dir_results):
|
|
878
|
+
(dirs if is_directory else files).append(entry)
|
|
864
879
|
if top_down:
|
|
865
880
|
yield self, [d.name for d in dirs], [f.name for f in files]
|
|
866
881
|
for d in dirs:
|
|
@@ -9,12 +9,10 @@ from __future__ import annotations
|
|
|
9
9
|
from base64 import b64encode
|
|
10
10
|
from collections.abc import AsyncGenerator, AsyncIterator, Callable
|
|
11
11
|
from os import getenv, linesep
|
|
12
|
-
from typing import
|
|
12
|
+
from typing import TYPE_CHECKING, Literal
|
|
13
13
|
from xml.etree.ElementTree import XMLParser
|
|
14
14
|
|
|
15
15
|
import msgspec
|
|
16
|
-
|
|
17
|
-
from asanypath.cloud import CloudPathMixin
|
|
18
16
|
from asanypath_native import (
|
|
19
17
|
http_delete,
|
|
20
18
|
http_exists,
|
|
@@ -27,12 +25,15 @@ from asanypath_native import (
|
|
|
27
25
|
http_scrape_links,
|
|
28
26
|
)
|
|
29
27
|
|
|
28
|
+
from asanypath.cloud import CloudPathMixin
|
|
29
|
+
|
|
30
30
|
try:
|
|
31
31
|
from asanypath_native import http_request
|
|
32
32
|
except ImportError: # pragma: no cover
|
|
33
33
|
http_request = None # type: ignore[assignment]
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from typing import Self
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
class _TagCollector:
|
|
@@ -306,7 +307,7 @@ class HTTPPath(CloudPathMixin):
|
|
|
306
307
|
return False # pragma: no cover
|
|
307
308
|
return await self.exists() # pragma: no cover
|
|
308
309
|
|
|
309
|
-
async def iterdir(self) -> AsyncIterator[
|
|
310
|
+
async def iterdir(self) -> AsyncIterator[Self]:
|
|
310
311
|
"""Yield child paths by scraping links from this URL.
|
|
311
312
|
|
|
312
313
|
Uses the path's ``listing`` configuration (default ``"auto"``).
|
|
@@ -334,23 +335,26 @@ class HTTPPath(CloudPathMixin):
|
|
|
334
335
|
headers=self._merge_headers(None),
|
|
335
336
|
)
|
|
336
337
|
for link in urls:
|
|
337
|
-
|
|
338
|
-
|
|
338
|
+
yield type(self)(
|
|
339
|
+
link,
|
|
340
|
+
listing=self._listing,
|
|
341
|
+
listing_attr=self._listing_attr,
|
|
342
|
+
)
|
|
339
343
|
|
|
340
344
|
async def walk(
|
|
341
345
|
self,
|
|
342
346
|
top_down: bool = True,
|
|
343
347
|
on_error: Callable[[OSError], object] | None = None,
|
|
344
348
|
follow_symlinks: bool = False,
|
|
345
|
-
) -> AsyncIterator[tuple[
|
|
349
|
+
) -> AsyncIterator[tuple[Self, list[str], list[str]]]:
|
|
346
350
|
"""Recursively walk the listing tree, yielding (root, dirs, files).
|
|
347
351
|
|
|
348
352
|
``follow_symlinks`` is accepted for API parity; HTTP listings have no symlinks.
|
|
349
353
|
"""
|
|
350
354
|
if self._resolve_listing() is None:
|
|
351
355
|
raise ValueError("HTTPPath.walk() called with listing=None")
|
|
352
|
-
dirs: list[
|
|
353
|
-
files: list[
|
|
356
|
+
dirs: list[Self] = []
|
|
357
|
+
files: list[Self] = []
|
|
354
358
|
try:
|
|
355
359
|
async for child in self.iterdir():
|
|
356
360
|
if child._trailing_slash:
|
|
@@ -372,7 +376,9 @@ class HTTPPath(CloudPathMixin):
|
|
|
372
376
|
if not top_down:
|
|
373
377
|
yield self, [d.name for d in dirs], [f.name for f in files]
|
|
374
378
|
|
|
375
|
-
async def glob(
|
|
379
|
+
async def glob(
|
|
380
|
+
self, pattern: str, *, case_sensitive: bool | None = None
|
|
381
|
+
) -> AsyncIterator[Self]:
|
|
376
382
|
"""Match descendants against a glob pattern (uses ``listing``)."""
|
|
377
383
|
if self._resolve_listing() is None:
|
|
378
384
|
raise ValueError("HTTPPath.glob() called with listing=None")
|
|
@@ -409,10 +415,10 @@ class HTTPPath(CloudPathMixin):
|
|
|
409
415
|
case _:
|
|
410
416
|
return text.replace(newline, "\n")
|
|
411
417
|
|
|
412
|
-
async def rename(self, target: str, *, force: bool = False) ->
|
|
418
|
+
async def rename(self, target: str, *, force: bool = False) -> Self:
|
|
413
419
|
return await super().rename(target, force=force)
|
|
414
420
|
|
|
415
|
-
async def replace(self, target: str) ->
|
|
421
|
+
async def replace(self, target: str) -> Self:
|
|
416
422
|
return await self.rename(target, force=True)
|
|
417
423
|
|
|
418
424
|
async def touch(self, mode: int = 0o666, exist_ok: bool = True) -> None:
|
|
@@ -433,13 +439,15 @@ class HTTPPath(CloudPathMixin):
|
|
|
433
439
|
except Exception as e:
|
|
434
440
|
raise OSError(f"Failed to delete {self}: {e}") from None
|
|
435
441
|
|
|
436
|
-
async def rglob(
|
|
442
|
+
async def rglob(
|
|
443
|
+
self, pattern: str, *, case_sensitive: bool | None = None
|
|
444
|
+
) -> AsyncIterator[Self]:
|
|
437
445
|
if self._resolve_listing() is None:
|
|
438
446
|
raise ValueError("HTTPPath.rglob() called with listing=None")
|
|
439
447
|
async for entry in self.glob(pattern, case_sensitive=case_sensitive):
|
|
440
448
|
yield entry
|
|
441
449
|
|
|
442
|
-
async def rmdir(self) -> None:
|
|
450
|
+
async def rmdir(self, *, recursive: bool = False) -> None:
|
|
443
451
|
raise NotImplementedError(f"{type(self).__name__} does not support rmdir")
|
|
444
452
|
|
|
445
453
|
async def stat(self, *, follow_symlinks: bool = True):
|
|
File without changes
|
|
@@ -39,16 +39,15 @@ layer. Call :func:`disconnect_all` to close everything (tests/shutdown).
|
|
|
39
39
|
|
|
40
40
|
from __future__ import annotations
|
|
41
41
|
|
|
42
|
+
import asyncio
|
|
42
43
|
import stat as stat_module
|
|
43
44
|
from collections.abc import AsyncIterator
|
|
44
45
|
from os import getenv
|
|
45
46
|
from pathlib import Path
|
|
46
47
|
from time import time
|
|
47
48
|
from types import SimpleNamespace
|
|
48
|
-
from typing import TYPE_CHECKING, NoReturn
|
|
49
|
+
from typing import TYPE_CHECKING, NoReturn, overload
|
|
49
50
|
|
|
50
|
-
from asanypath.cloud import CloudPathMixin
|
|
51
|
-
from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
|
|
52
51
|
from asanypath_native import (
|
|
53
52
|
ssh_disconnect_all,
|
|
54
53
|
ssh_list,
|
|
@@ -65,6 +64,9 @@ from asanypath_native import (
|
|
|
65
64
|
ssh_write,
|
|
66
65
|
)
|
|
67
66
|
|
|
67
|
+
from asanypath.cloud import CloudPathMixin
|
|
68
|
+
from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
|
|
69
|
+
|
|
68
70
|
if TYPE_CHECKING:
|
|
69
71
|
from typing import Self
|
|
70
72
|
|
|
@@ -76,19 +78,46 @@ def disconnect_all() -> None:
|
|
|
76
78
|
|
|
77
79
|
_UNSET = object()
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
# Max concurrent in-flight SFTP delete ops during recursive rmdir. Deletes
|
|
82
|
+
# pipeline safely over the pooled session (unlike concurrent writes); bounded so
|
|
83
|
+
# we don't flood the connection/server.
|
|
84
|
+
_RMDIR_CONCURRENCY = 16
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@overload
|
|
88
|
+
def _map_native_error(exc: Exception, path: str) -> NoReturn: ...
|
|
89
|
+
@overload
|
|
90
|
+
def _map_native_error(
|
|
91
|
+
exc: Exception, path: str, *, ignore: type[BaseException] | tuple[type[BaseException], ...]
|
|
92
|
+
) -> None: ...
|
|
93
|
+
def _map_native_error(
|
|
94
|
+
exc: Exception,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
ignore: type[BaseException] | tuple[type[BaseException], ...] = (),
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Translate a native russh/SFTP error into the stdlib OSError family.
|
|
100
|
+
|
|
101
|
+
Errors whose mapped type matches *ignore* are swallowed (return) instead of
|
|
102
|
+
raised, so callers can express ``missing_ok``/``exist_ok`` without re-catching.
|
|
103
|
+
"""
|
|
82
104
|
msg = str(exc).lower()
|
|
105
|
+
mapped: Exception
|
|
83
106
|
if "no such file" in msg or "not found" in msg or "does not exist" in msg:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
107
|
+
mapped = FileNotFoundError(2, "No such file or directory", path)
|
|
108
|
+
elif "permission denied" in msg:
|
|
109
|
+
mapped = PermissionError(13, "Permission denied", path)
|
|
110
|
+
elif "not a directory" in msg:
|
|
111
|
+
mapped = NotADirectoryError(20, "Not a directory", path)
|
|
112
|
+
elif "not empty" in msg:
|
|
113
|
+
mapped = OSError(39, "Directory not empty", path)
|
|
114
|
+
elif "already exists" in msg or "file exists" in msg:
|
|
115
|
+
mapped = FileExistsError(17, "File exists", path)
|
|
116
|
+
else:
|
|
117
|
+
raise exc
|
|
118
|
+
if ignore and isinstance(mapped, ignore):
|
|
119
|
+
return
|
|
120
|
+
raise mapped from exc
|
|
92
121
|
|
|
93
122
|
|
|
94
123
|
def _discover_ssh_config() -> list[str]:
|
|
@@ -447,11 +476,7 @@ class SSHPath(CloudPathMixin):
|
|
|
447
476
|
try:
|
|
448
477
|
await ssh_unlink(path=self._item_path, **self._native_kwargs)
|
|
449
478
|
except Exception as e: # noqa: BLE001
|
|
450
|
-
|
|
451
|
-
_map_native_error(e, str(self))
|
|
452
|
-
except FileNotFoundError:
|
|
453
|
-
if not missing_ok:
|
|
454
|
-
raise
|
|
479
|
+
_map_native_error(e, str(self), ignore=FileNotFoundError if missing_ok else ())
|
|
455
480
|
|
|
456
481
|
async def mkdir(self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None:
|
|
457
482
|
if not (parents or exist_ok) and await self.exists():
|
|
@@ -460,24 +485,55 @@ class SSHPath(CloudPathMixin):
|
|
|
460
485
|
await ssh_mkdir(path=self._item_path, parents=parents, **self._native_kwargs)
|
|
461
486
|
except Exception as e: # noqa: BLE001
|
|
462
487
|
try:
|
|
463
|
-
_map_native_error(e, str(self))
|
|
464
|
-
except
|
|
465
|
-
|
|
466
|
-
|
|
488
|
+
_map_native_error(e, str(self), ignore=FileExistsError if exist_ok else ())
|
|
489
|
+
except Exception:
|
|
490
|
+
# Some servers (e.g. atmoz/sftp) return a generic, unclassifiable
|
|
491
|
+
# error for an existing dir; re-check so exist_ok stays idempotent.
|
|
492
|
+
if exist_ok and await self.is_dir():
|
|
493
|
+
return
|
|
494
|
+
raise
|
|
467
495
|
|
|
468
496
|
async def rmdir(self, *, recursive: bool = False) -> None:
|
|
469
|
-
if recursive:
|
|
470
|
-
#
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
497
|
+
if not recursive:
|
|
498
|
+
# ssh_list yields nothing for an empty dir; a clean ENOTEMPTY beats
|
|
499
|
+
# the raw server error _map_native_error would otherwise classify.
|
|
500
|
+
async for _ in self.iterdir():
|
|
501
|
+
raise OSError(39, f"Directory not empty: '{self}'")
|
|
502
|
+
await self._native_rmdir()
|
|
503
|
+
return
|
|
504
|
+
await self._rmtree()
|
|
505
|
+
|
|
506
|
+
async def _native_rmdir(self) -> None:
|
|
476
507
|
try:
|
|
477
508
|
await ssh_rmdir(path=self._item_path, **self._native_kwargs)
|
|
478
509
|
except Exception as e: # noqa: BLE001
|
|
479
510
|
_map_native_error(e, str(self))
|
|
480
511
|
|
|
512
|
+
async def _rmtree(self) -> None:
|
|
513
|
+
await self._rmtree_bounded(asyncio.Semaphore(_RMDIR_CONCURRENCY))
|
|
514
|
+
|
|
515
|
+
async def _rmtree_bounded(self, sem: asyncio.Semaphore) -> None:
|
|
516
|
+
"""Delete this directory and its contents with bounded concurrency.
|
|
517
|
+
|
|
518
|
+
SFTP has no recursive remove; children are removed concurrently (the
|
|
519
|
+
semaphore bounds in-flight deletes) and the semaphore is only held around
|
|
520
|
+
a single network op — never across recursion — so it cannot deadlock.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
async def _remove(child: Self) -> None:
|
|
524
|
+
if await child.is_dir(): # cached from iterdir — no round-trip
|
|
525
|
+
await child._rmtree_bounded(sem)
|
|
526
|
+
else:
|
|
527
|
+
async with sem:
|
|
528
|
+
await child.unlink()
|
|
529
|
+
|
|
530
|
+
children = [_remove(child) async for child in self.iterdir()]
|
|
531
|
+
for result in await asyncio.gather(*children, return_exceptions=True):
|
|
532
|
+
if isinstance(result, BaseException):
|
|
533
|
+
raise result
|
|
534
|
+
async with sem:
|
|
535
|
+
await self._native_rmdir()
|
|
536
|
+
|
|
481
537
|
async def rename(self, target: str | Self, *, force: bool = False) -> Self:
|
|
482
538
|
target_path = target if isinstance(target, type(self)) else type(self)(str(target))
|
|
483
539
|
from asanypath._transfer import async_destination_state, require_force
|
|
@@ -538,10 +594,10 @@ class SSHPath(CloudPathMixin):
|
|
|
538
594
|
try:
|
|
539
595
|
entries = await ssh_list(path=self._item_path, **self._native_kwargs)
|
|
540
596
|
except Exception as e: # noqa: BLE001
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
597
|
+
# A missing path / non-dir surfaces as "not a directory" for iterdir;
|
|
598
|
+
# other failures keep their mapped type.
|
|
599
|
+
_map_native_error(e, str(self), ignore=FileNotFoundError)
|
|
600
|
+
raise NotADirectoryError(20, f"Not a directory: '{self}'") from e
|
|
545
601
|
for name, size, mtime, atime, uid, gid, permissions in entries:
|
|
546
602
|
if name in (".", ".."):
|
|
547
603
|
continue
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, NoReturn
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from anyio import Path as AioPath
|
|
14
|
+
|
|
15
|
+
from asanypath.common import CommonPurePathMixin
|
|
16
|
+
from asanypath.exceptions import UnsupportedProtocolError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnsupportedProtocolPath(CommonPurePathMixin):
|
|
20
|
+
"""Path-like placeholder for unknown protocols.
|
|
21
|
+
|
|
22
|
+
Pure path operations continue to work, while backend and I/O operations
|
|
23
|
+
fail with UnsupportedProtocolError when invoked.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_asanypath_placeholder = True
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
part: str | Path | AioPath | CommonPurePathMixin | None = None,
|
|
31
|
+
*parts: str | Path | AioPath | CommonPurePathMixin,
|
|
32
|
+
) -> None:
|
|
33
|
+
raw = str(part)
|
|
34
|
+
parsed = urlsplit(raw)
|
|
35
|
+
self.protocol = (parsed.scheme or "unsupported").lower()
|
|
36
|
+
super().__init__(part, *parts)
|
|
37
|
+
|
|
38
|
+
def _raise_unsupported(self, operation: str) -> NoReturn:
|
|
39
|
+
raise UnsupportedProtocolError(
|
|
40
|
+
f"Unsupported protocol: {self.protocol} (operation: {operation}, path: {self})"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __bytes__(self) -> bytes:
|
|
44
|
+
return str(self).encode()
|
|
45
|
+
|
|
46
|
+
def __fspath__(self) -> str:
|
|
47
|
+
self._raise_unsupported("__fspath__")
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def cwd(cls) -> NoReturn:
|
|
51
|
+
raise UnsupportedProtocolError("Unsupported protocol operation: cwd")
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def home(cls) -> NoReturn:
|
|
55
|
+
raise UnsupportedProtocolError("Unsupported protocol operation: home")
|
|
56
|
+
|
|
57
|
+
def __getattr__(self, name: str) -> Any:
|
|
58
|
+
# Reached only after normal MRO lookup fails, i.e. for every backend/I/O
|
|
59
|
+
# operation (pure-path ops live on CommonPurePathMixin and resolve before
|
|
60
|
+
# here). Underscore/dunder names stay genuine AttributeErrors so pickle and
|
|
61
|
+
# getattr(obj, name, default) capability probes keep working.
|
|
62
|
+
if name.startswith("_"):
|
|
63
|
+
raise AttributeError(name)
|
|
64
|
+
self._raise_unsupported(name)
|
|
@@ -391,7 +391,7 @@ def _make_nested_tree():
|
|
|
391
391
|
@pytest.mark.parametrize(
|
|
392
392
|
"setup,args,expected_exit,expected_in,not_in,call_with",
|
|
393
393
|
[
|
|
394
|
-
pytest.param(_ls_empty, ["ls", "s3://bucket/"],
|
|
394
|
+
pytest.param(_ls_empty, ["ls", "s3://bucket/"], 1, [], ["empty"], None, id="empty"),
|
|
395
395
|
pytest.param(
|
|
396
396
|
_ls_items, ["ls", "s3://bucket/"], 0, ["file1.txt", "file2.txt"], [], None, id="items"
|
|
397
397
|
),
|
|
@@ -410,7 +410,7 @@ def _make_nested_tree():
|
|
|
410
410
|
pytest.param(
|
|
411
411
|
_ls_nested, ["ls", "-r", "s3://bucket/"], 0, ["deep.txt"], [], None, id="unbounded"
|
|
412
412
|
),
|
|
413
|
-
pytest.param(_ls_empty, ["ls"],
|
|
413
|
+
pytest.param(_ls_empty, ["ls"], 1, [], [], ".", id="no_arg_default"),
|
|
414
414
|
pytest.param(
|
|
415
415
|
_ls_items,
|
|
416
416
|
["ls", "-1", "s3://bucket/"],
|