Skip to content

Commit 1aec8ff

Browse files
authored
Add UnsupportedOperation exception (#474)
* upath: provide UnsupportedOperation exception class * tests: add backport tests * upath: more typing and signature fixes * upath.implementatios.local: fix type error on <3.12
1 parent 78825b6 commit 1aec8ff

File tree

7 files changed

+144
-42
lines changed

7 files changed

+144
-42
lines changed

upath/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
__version__ = "not-installed"
1111

1212
if TYPE_CHECKING:
13+
from upath.core import UnsupportedOperation
1314
from upath.core import UPath
1415

15-
__all__ = ["UPath"]
16+
__all__ = ["UPath", "UnsupportedOperation"]
1617

1718

1819
def __getattr__(name):
@@ -21,5 +22,10 @@ def __getattr__(name):
2122

2223
globals()["UPath"] = UPath
2324
return UPath
25+
elif name == "UnsupportedOperation":
26+
from upath.core import UnsupportedOperation
27+
28+
globals()["UnsupportedOperation"] = UnsupportedOperation
29+
return UnsupportedOperation
2430
else:
2531
raise AttributeError(f"module {__name__} has no attribute {name}")

upath/core.py

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@
4949
from upath.types import WritablePath
5050
from upath.types import WritablePathLike
5151

52+
if sys.version_info >= (3, 13):
53+
from pathlib import UnsupportedOperation
54+
else:
55+
UnsupportedOperation = NotImplementedError
56+
"""Raised when an unsupported operation is called on a path object."""
57+
5258
if TYPE_CHECKING:
5359
import upath.implementations as _uimpl
5460

@@ -63,7 +69,10 @@
6369
_MT = TypeVar("_MT")
6470
_WT = TypeVar("_WT", bound="WritablePath")
6571

66-
__all__ = ["UPath"]
72+
__all__ = [
73+
"UPath",
74+
"UnsupportedOperation",
75+
]
6776

6877
_FSSPEC_HAS_WORKING_GLOB = None
6978

@@ -103,7 +112,7 @@ def _buffering2blocksize(mode: str, buffering: int) -> int | None:
103112

104113

105114
def _raise_unsupported(cls_name: str, method: str) -> NoReturn:
106-
raise NotImplementedError(f"{cls_name}.{method}() is unsupported")
115+
raise UnsupportedOperation(f"{cls_name}.{method}() is unsupported")
107116

108117

109118
class _UPathMeta(ABCMeta):
@@ -311,7 +320,7 @@ def path(self) -> str:
311320
# For relative paths, we need to resolve to absolute path
312321
current_dir = self.cwd() # type: ignore[attr-defined]
313322
except NotImplementedError:
314-
raise NotImplementedError(
323+
raise UnsupportedOperation(
315324
f"fsspec paths can not be relative and"
316325
f" {type(self).__name__}.cwd() is unsupported"
317326
) from None
@@ -1513,20 +1522,20 @@ def glob(
15131522
self,
15141523
pattern: str,
15151524
*,
1516-
case_sensitive: bool = UNSET_DEFAULT,
1517-
recurse_symlinks: bool = UNSET_DEFAULT,
1525+
case_sensitive: bool | None = None,
1526+
recurse_symlinks: bool = False,
15181527
) -> Iterator[Self]:
15191528
"""Iterate over this subtree and yield all existing files (of any
15201529
kind, including directories) matching the given relative pattern."""
1521-
if case_sensitive is not UNSET_DEFAULT:
1530+
if case_sensitive is not None:
15221531
warnings.warn(
15231532
"UPath.glob(): case_sensitive is currently ignored.",
15241533
UserWarning,
15251534
stacklevel=2,
15261535
)
1527-
if recurse_symlinks is not UNSET_DEFAULT:
1536+
if recurse_symlinks:
15281537
warnings.warn(
1529-
"UPath.glob(): recurse_symlinks is currently ignored.",
1538+
"UPath.glob(): recurse_symlinks=True is currently ignored.",
15301539
UserWarning,
15311540
stacklevel=2,
15321541
)
@@ -1543,22 +1552,22 @@ def rglob(
15431552
self,
15441553
pattern: str,
15451554
*,
1546-
case_sensitive: bool = UNSET_DEFAULT,
1547-
recurse_symlinks: bool = UNSET_DEFAULT,
1555+
case_sensitive: bool | None = None,
1556+
recurse_symlinks: bool = False,
15481557
) -> Iterator[Self]:
15491558
"""Recursively yield all existing files (of any kind, including
15501559
directories) matching the given relative pattern, anywhere in
15511560
this subtree.
15521561
"""
1553-
if case_sensitive is not UNSET_DEFAULT:
1562+
if case_sensitive is not None:
15541563
warnings.warn(
15551564
"UPath.glob(): case_sensitive is currently ignored.",
15561565
UserWarning,
15571566
stacklevel=2,
15581567
)
1559-
if recurse_symlinks is not UNSET_DEFAULT:
1568+
if recurse_symlinks:
15601569
warnings.warn(
1561-
"UPath.glob(): recurse_symlinks is currently ignored.",
1570+
"UPath.glob(): recurse_symlinks=True is currently ignored.",
15621571
UserWarning,
15631572
stacklevel=2,
15641573
)
@@ -1971,13 +1980,39 @@ def is_relative_to(
19711980
return self == other or other in self.parents
19721981

19731982
def hardlink_to(self, target: ReadablePathLike) -> None:
1974-
raise NotImplementedError
1983+
_raise_unsupported(type(self).__name__, "hardlink_to")
1984+
1985+
def full_match(
1986+
self,
1987+
pattern: str | SupportsPathLike,
1988+
*,
1989+
case_sensitive: bool | None = None,
1990+
) -> bool:
1991+
"""Match this path against the provided glob-style pattern.
1992+
Return True if matching is successful, False otherwise.
1993+
"""
1994+
if case_sensitive is not None:
1995+
warnings.warn(
1996+
f"{type(self).__name__}.full_match(): case_sensitive"
1997+
" is currently ignored.",
1998+
UserWarning,
1999+
stacklevel=2,
2000+
)
2001+
return super().full_match(str(pattern))
19752002

1976-
def match(self, pattern: str) -> bool:
1977-
# fixme: hacky emulation of match. needs tests...
1978-
if not pattern:
2003+
def match(
2004+
self,
2005+
path_pattern: str | SupportsPathLike,
2006+
*,
2007+
case_sensitive: bool | None = None,
2008+
) -> bool:
2009+
"""Match this path against the provided non-recursive glob-style pattern.
2010+
Return True if matching is successful, False otherwise.
2011+
"""
2012+
path_pattern = str(path_pattern)
2013+
if not path_pattern:
19792014
raise ValueError("pattern cannot be empty")
1980-
return self.full_match(pattern.replace("**", "*"))
2015+
return self.full_match(path_pattern.replace("**", "*"))
19812016

19822017
@classmethod
19832018
def __get_pydantic_core_schema__(

upath/extensions.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919
from upath._chain import Chain
2020
from upath._chain import ChainSegment
2121
from upath._stat import UPathStatResult
22+
from upath.core import UnsupportedOperation
2223
from upath.core import UPath
2324
from upath.types import UNSET_DEFAULT
2425
from upath.types import JoinablePathLike
2526
from upath.types import PathInfo
2627
from upath.types import ReadablePath
2728
from upath.types import ReadablePathLike
29+
from upath.types import SupportsPathLike
2830
from upath.types import UPathParser
2931
from upath.types import WritablePathLike
3032

@@ -249,8 +251,8 @@ def glob(
249251
self,
250252
pattern: str,
251253
*,
252-
case_sensitive: bool = UNSET_DEFAULT,
253-
recurse_symlinks: bool = UNSET_DEFAULT,
254+
case_sensitive: bool | None = None,
255+
recurse_symlinks: bool = False,
254256
) -> Iterator[Self]:
255257
for p in self.__wrapped__.glob(
256258
pattern, case_sensitive=case_sensitive, recurse_symlinks=recurse_symlinks
@@ -261,8 +263,8 @@ def rglob(
261263
self,
262264
pattern: str,
263265
*,
264-
case_sensitive: bool = UNSET_DEFAULT,
265-
recurse_symlinks: bool = UNSET_DEFAULT,
266+
case_sensitive: bool | None = None,
267+
recurse_symlinks: bool = False,
266268
) -> Iterator[Self]:
267269
for p in self.__wrapped__.rglob(
268270
pattern, case_sensitive=case_sensitive, recurse_symlinks=recurse_symlinks
@@ -357,11 +359,11 @@ def samefile(self, other_path) -> bool:
357359

358360
@classmethod
359361
def cwd(cls) -> Self:
360-
raise NotImplementedError
362+
raise UnsupportedOperation(".cwd() not supported")
361363

362364
@classmethod
363365
def home(cls) -> Self:
364-
raise NotImplementedError
366+
raise UnsupportedOperation(".home() not supported")
365367

366368
def relative_to( # type: ignore[override]
367369
self,
@@ -382,7 +384,7 @@ def is_relative_to(self, other, /, *_deprecated) -> bool: # type: ignore[overri
382384
def hardlink_to(self, target: ReadablePathLike) -> None:
383385
return self.__wrapped__.hardlink_to(target)
384386

385-
def match(self, pattern: str) -> bool:
387+
def match(self, pattern: str, *, case_sensitive: bool | None = None) -> bool:
386388
return self.__wrapped__.match(pattern)
387389

388390
@property
@@ -510,8 +512,13 @@ def parent(self) -> Self:
510512
def parents(self) -> Sequence[Self]:
511513
return tuple(self._from_upath(p) for p in self.__wrapped__.parents)
512514

513-
def full_match(self, pattern: str) -> bool:
514-
return self.__wrapped__.full_match(pattern)
515+
def full_match(
516+
self,
517+
pattern: str | SupportsPathLike,
518+
*,
519+
case_sensitive: bool | None = None,
520+
) -> bool:
521+
return self.__wrapped__.full_match(pattern, case_sensitive=case_sensitive)
515522

516523

517524
UPath.register(ProxyUPath)

upath/implementations/data.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Sequence
55
from typing import TYPE_CHECKING
66

7+
from upath.core import UnsupportedOperation
78
from upath.core import UPath
89
from upath.types import JoinablePathLike
910

@@ -44,24 +45,24 @@ def __str__(self) -> str:
4445
return self.parser.join(*self._raw_urlpaths)
4546

4647
def with_segments(self, *pathsegments: JoinablePathLike) -> Self:
47-
raise NotImplementedError("path operation not supported by DataPath")
48+
raise UnsupportedOperation("path operation not supported by DataPath")
4849

4950
def with_suffix(self, suffix: str) -> Self:
50-
raise NotImplementedError("path operation not supported by DataPath")
51+
raise UnsupportedOperation("path operation not supported by DataPath")
5152

5253
def mkdir(
5354
self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False
5455
) -> None:
5556
raise FileExistsError(str(self))
5657

5758
def write_bytes(self, data: bytes) -> int:
58-
raise NotImplementedError("DataPath does not support writing")
59+
raise UnsupportedOperation("DataPath does not support writing")
5960

6061
def write_text(
6162
self,
6263
data: str,
63-
encoding: str | None = ...,
64-
errors: str | None = ...,
65-
newline: str | None = ...,
64+
encoding: str | None = None,
65+
errors: str | None = None,
66+
newline: str | None = None,
6667
) -> int:
67-
raise NotImplementedError("DataPath does not support writing")
68+
raise UnsupportedOperation("DataPath does not support writing")

upath/implementations/http.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,14 @@ def path(self) -> str:
6464
sr = urlsplit(super().path)
6565
return sr._replace(path=sr.path or "/").geturl()
6666

67-
def is_file(self) -> bool:
67+
def is_file(self, *, follow_symlinks: bool = True) -> bool:
68+
if not follow_symlinks:
69+
warnings.warn(
70+
f"{type(self).__name__}.is_file(follow_symlinks=False):"
71+
" is currently ignored.",
72+
UserWarning,
73+
stacklevel=2,
74+
)
6875
try:
6976
next(super().iterdir())
7077
except (StopIteration, NotADirectoryError):
@@ -74,7 +81,14 @@ def is_file(self) -> bool:
7481
else:
7582
return False
7683

77-
def is_dir(self) -> bool:
84+
def is_dir(self, *, follow_symlinks: bool = True) -> bool:
85+
if not follow_symlinks:
86+
warnings.warn(
87+
f"{type(self).__name__}.is_dir(follow_symlinks=False):"
88+
" is currently ignored.",
89+
UserWarning,
90+
stacklevel=2,
91+
)
7892
try:
7993
next(super().iterdir())
8094
except (StopIteration, NotADirectoryError):

upath/implementations/local.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from upath._chain import ChainSegment
2121
from upath._chain import FSSpecChainParser
2222
from upath._protocol import compatible_protocol
23+
from upath.core import UnsupportedOperation
2324
from upath.core import UPath
2425
from upath.core import _UPathMixin
2526
from upath.types import UNSET_DEFAULT
@@ -393,10 +394,17 @@ def info(self) -> PathInfo:
393394

394395
if sys.version_info < (3, 13):
395396

396-
def full_match(self, pattern: str) -> bool:
397+
def full_match(
398+
self,
399+
pattern: str | os.PathLike[str],
400+
*,
401+
case_sensitive: bool | None = None,
402+
) -> bool:
397403
# hacky workaround for missing pathlib.Path.full_match in python < 3.13
398404
# todo: revisit
399-
return self.match(pattern)
405+
return self.match(
406+
pattern, # type: ignore[arg-type]
407+
)
400408

401409
@classmethod
402410
def from_uri(cls, uri: str, **storage_options: Any) -> Self:
@@ -422,7 +430,7 @@ def hardlink_to(self, target: ReadablePathLike) -> None:
422430
try:
423431
os.link(target, self) # type: ignore[arg-type]
424432
except AttributeError:
425-
raise NotImplementedError
433+
raise UnsupportedOperation("hardlink operation not supported")
426434

427435
if not hasattr(pathlib.Path, "_copy_from"):
428436

@@ -464,7 +472,7 @@ def __new__(
464472
chain_parser: FSSpecChainParser = DEFAULT_CHAIN_PARSER,
465473
**storage_options: Any,
466474
) -> WindowsUPath:
467-
raise NotImplementedError(
475+
raise UnsupportedOperation(
468476
f"cannot instantiate {cls.__name__} on your system"
469477
)
470478

@@ -480,7 +488,7 @@ def __new__(
480488
chain_parser: FSSpecChainParser = DEFAULT_CHAIN_PARSER,
481489
**storage_options: Any,
482490
) -> PosixUPath:
483-
raise NotImplementedError(
491+
raise UnsupportedOperation(
484492
f"cannot instantiate {cls.__name__} on your system"
485493
)
486494

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import os
2+
from pathlib import PosixPath
3+
from pathlib import WindowsPath
4+
5+
import pytest
6+
7+
from upath import UnsupportedOperation
8+
from upath.implementations.local import PosixUPath
9+
from upath.implementations.local import WindowsUPath
10+
11+
12+
def test_provides_unsupportedoperation():
13+
assert issubclass(UnsupportedOperation, NotImplementedError)
14+
15+
16+
_win_only = pytest.mark.skipif(os.name != "nt", reason="windows only")
17+
_posix_only = pytest.mark.skipif(os.name == "nt", reason="posix only")
18+
19+
20+
@pytest.mark.parametrize(
21+
"wrong_cls",
22+
[
23+
pytest.param(PosixPath, marks=_win_only),
24+
pytest.param(PosixUPath, marks=_win_only),
25+
pytest.param(WindowsPath, marks=_posix_only),
26+
pytest.param(WindowsUPath, marks=_posix_only),
27+
],
28+
)
29+
def test_unsupportedoperation_catches_pathlib_errors(wrong_cls):
30+
with pytest.raises(UnsupportedOperation):
31+
wrong_cls(".")

0 commit comments

Comments
 (0)