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.
Files changed (71) hide show
  1. {asanypath-2.1.0 → asanypath-2.1.1}/.github/workflows/publish.yml +2 -2
  2. {asanypath-2.1.0 → asanypath-2.1.1}/.github/workflows/unittests.yml +1 -1
  3. {asanypath-2.1.0 → asanypath-2.1.1}/.gitignore +1 -0
  4. {asanypath-2.1.0 → asanypath-2.1.1}/PKG-INFO +2 -2
  5. {asanypath-2.1.0 → asanypath-2.1.1}/hatch_build.py +1 -1
  6. {asanypath-2.1.0 → asanypath-2.1.1}/pyproject.toml +4 -1
  7. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/_transfer.py +13 -5
  8. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/_version.py +2 -2
  9. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/artifactory.py +3 -2
  10. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/asanypath.py +1 -1
  11. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/asanypath.pyi +1 -1
  12. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/cli.py +2 -2
  13. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/cloud.py +28 -13
  14. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/http.py +23 -15
  15. asanypath-2.1.1/src/asanypath/py.typed +0 -0
  16. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/ssh.py +90 -34
  17. asanypath-2.1.1/src/asanypath/unsupported.py +64 -0
  18. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_cli.py +2 -2
  19. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_cloudpathmixin.py +2 -2
  20. asanypath-2.1.1/tests/test_py_typed.py +14 -0
  21. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_ssh.py +14 -0
  22. asanypath-2.1.1/uv.lock +2831 -0
  23. asanypath-2.1.0/src/asanypath/unsupported.py +0 -202
  24. asanypath-2.1.0/src/asanypath_native.pyi +0 -123
  25. asanypath-2.1.0/uv.lock +0 -2798
  26. {asanypath-2.1.0 → asanypath-2.1.1}/.copilot/skills/async-python-lib/SKILL.md +0 -0
  27. {asanypath-2.1.0 → asanypath-2.1.1}/.copilot/skills/gitguro/SKILL.md +0 -0
  28. {asanypath-2.1.0 → asanypath-2.1.1}/.github/CODEOWNERS +0 -0
  29. {asanypath-2.1.0 → asanypath-2.1.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  30. {asanypath-2.1.0 → asanypath-2.1.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  31. {asanypath-2.1.0 → asanypath-2.1.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {asanypath-2.1.0 → asanypath-2.1.1}/.github/dependabot.yml +0 -0
  33. {asanypath-2.1.0 → asanypath-2.1.1}/.pre-commit-config.yaml +0 -0
  34. {asanypath-2.1.0 → asanypath-2.1.1}/CODE_OF_CONDUCT.md +0 -0
  35. {asanypath-2.1.0 → asanypath-2.1.1}/CONTRIBUTING.md +0 -0
  36. {asanypath-2.1.0 → asanypath-2.1.1}/LICENSE +0 -0
  37. {asanypath-2.1.0 → asanypath-2.1.1}/README.md +0 -0
  38. {asanypath-2.1.0 → asanypath-2.1.1}/SECURITY.md +0 -0
  39. {asanypath-2.1.0 → asanypath-2.1.1}/asanypath.png +0 -0
  40. {asanypath-2.1.0 → asanypath-2.1.1}/compose.yml +0 -0
  41. {asanypath-2.1.0 → asanypath-2.1.1}/scripts/bench.py +0 -0
  42. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/__init__.py +0 -0
  43. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/__init__.pyi +0 -0
  44. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/_credstore.py +0 -0
  45. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/azure.py +3 -3
  46. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/batcher.py +0 -0
  47. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/common.py +0 -0
  48. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/exceptions.py +0 -0
  49. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/ftp.py +0 -0
  50. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/gcs.py +3 -3
  51. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/local.py +0 -0
  52. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/options.py +0 -0
  53. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/s3.py +10 -10
  54. {asanypath-2.1.0 → asanypath-2.1.1}/src/asanypath/sync.py +0 -0
  55. {asanypath-2.1.0 → asanypath-2.1.1}/tests/__init__.py +0 -0
  56. {asanypath-2.1.0 → asanypath-2.1.1}/tests/conftest.py +0 -0
  57. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_artifactory.py +0 -0
  58. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_asanypath.py +0 -0
  59. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_azure.py +0 -0
  60. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_batcher.py +0 -0
  61. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_commonpathmixin.py +0 -0
  62. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_credstore.py +0 -0
  63. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_ftp.py +0 -0
  64. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_gcs.py +0 -0
  65. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_http.py +0 -0
  66. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_http_auth.py +0 -0
  67. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_local.py +0 -0
  68. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_s3.py +0 -0
  69. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_sync.py +0 -0
  70. {asanypath-2.1.0 → asanypath-2.1.1}/tests/test_syncpath.py +0 -0
  71. {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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
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
@@ -15,6 +15,7 @@ __pycache__
15
15
  !.github/**
16
16
  !*.py
17
17
  !*.pyi
18
+ !py.typed
18
19
  !asanypath.png
19
20
  # auto-generated by hatch-vcs
20
21
  _version.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asanypath
3
- Version: 2.1.0
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.21.1
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'
@@ -15,6 +15,6 @@ class PinNativeHook(MetadataHookInterface):
15
15
  metadata["dependencies"] = [
16
16
  "anyio>=4",
17
17
  f"asanypath-native~={major}.{minor}.0",
18
- "msgspec>=0.21.1",
18
+ "msgspec>=0.22.0",
19
19
  "yarl>=1.2",
20
20
  ]
@@ -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
- return inspect.iscoroutinefunction(
89
- getattr(dst, "exists", None)
90
- ) and inspect.iscoroutinefunction(getattr(dst, "write_bytes", None))
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
- return callable(getattr(dst, "write_bytes", None))
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.0'
22
- __version_tuple__ = version_tuple = (2, 1, 0)
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``.
@@ -447,7 +447,7 @@ class AsAnyPath(AsAnyPurePath):
447
447
  pass
448
448
 
449
449
  @abstractmethod
450
- async def rmdir(self) -> None:
450
+ async def rmdir(self, *, recursive: bool = False) -> None:
451
451
  pass
452
452
 
453
453
  @abstractmethod
@@ -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
- console.print("[dim](empty)[/dim]")
1387
- return 0
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
- children = [c async for c in self.iterdir()]
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
- # Recursive: collect all files, then unlink
618
- files = []
619
- async for child in self.iterdir():
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.rmdir(recursive=True)
632
+ await child._rmtree()
622
633
  else:
623
- files.append(child)
624
- for f in files:
625
- await f.unlink()
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 = [e for e, d in zip(entries, is_dir_results) if d]
863
- files = [e for e, d in zip(entries, is_dir_results) if not d]
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 Literal, TypeVar
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
- T = TypeVar("T", bound="HTTPPath")
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[T]:
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
- # children keep the parent's listing config; _spawn copies it.
338
- yield self._child(link.rstrip("/") or link, _trailing_slash=link.endswith("/"))
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[T, list[str], list[str]]]:
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[T] = []
353
- files: list[T] = []
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(self, pattern: str, *, case_sensitive: bool | None = None) -> AsyncIterator[T]:
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) -> T:
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) -> T:
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(self, pattern: str, *, case_sensitive: bool | None = None) -> AsyncIterator[T]:
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
- def _map_native_error(exc: Exception, path: str) -> NoReturn:
81
- """Translate a native russh/SFTP error into the stdlib OSError family."""
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
- raise FileNotFoundError(2, "No such file or directory", path) from exc
85
- if "permission denied" in msg:
86
- raise PermissionError(13, "Permission denied", path) from exc
87
- if "not a directory" in msg:
88
- raise NotADirectoryError(20, "Not a directory", path) from exc
89
- if "already exists" in msg or "file exists" in msg:
90
- raise FileExistsError(17, "File exists", path) from exc
91
- raise exc
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
- try:
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 FileExistsError:
465
- if not exist_ok:
466
- raise
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
- # No native recursive remove; delete children depth-first.
471
- async for child in self.iterdir():
472
- if await child.is_dir():
473
- await child.rmdir(recursive=True)
474
- else:
475
- await child.unlink()
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
- try:
542
- _map_native_error(e, str(self))
543
- except FileNotFoundError as fe:
544
- raise NotADirectoryError(20, f"Not a directory: '{self}'") from fe
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/"], 0, ["empty"], [], None, id="empty"),
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"], 0, [], [], ".", id="no_arg_default"),
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/"],