Skip to content

Commit c31e910

Browse files
authored
Tests run extensions for posixupath (#477)
* tests: check proxyupath with posixupath and windowsupath * tests: more extensions tests adjustmens * upath: fixes for extensions, StatResultType * tests: fix permissions in lchmod test * upath: fix UPathStatResult attributes * tests: fix test cases and correct assumptions * upath: fix equality checks for extensions and locals * upath: fix equality NotImplemented behavior * upath: local add __hash__ * upath.types: further adjust StatResultType * upath.core: raise TypeError when creating with incompatible protocols * upath.implementations.local: fix _copy_from * upath.UPath: raise a TypeError subclass for incompatible protocols in constructor * tests: compatibility fixes for mount lchmod and relative_to * tests: add stat attribute test for debugging * tests: adjust stat debug test * upath.types: os.stat_result.st_birthtime_ns not on windows server...
1 parent 7773362 commit c31e910

File tree

7 files changed

+226
-32
lines changed

7 files changed

+226
-32
lines changed

upath/_stat.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,42 @@ def st_birthtime(self) -> int | float:
307307
pass
308308
raise AttributeError("birthtime")
309309

310+
@property
311+
def st_atime_ns(self) -> int:
312+
"""time of last access in nanoseconds"""
313+
try:
314+
return int(self._info["atime_ns"])
315+
except KeyError:
316+
pass
317+
atime = self.st_atime
318+
if isinstance(atime, float):
319+
return int(atime * 1e9)
320+
return atime * 1_000_000_000
321+
322+
@property
323+
def st_mtime_ns(self) -> int:
324+
"""time of last modification in nanoseconds"""
325+
try:
326+
return int(self._info["mtime_ns"])
327+
except KeyError:
328+
pass
329+
mtime = self.st_mtime
330+
if isinstance(mtime, float):
331+
return int(mtime * 1e9)
332+
return mtime * 1_000_000_000
333+
334+
@property
335+
def st_ctime_ns(self) -> int:
336+
"""time of last change in nanoseconds"""
337+
try:
338+
return int(self._info["ctime_ns"])
339+
except KeyError:
340+
pass
341+
ctime = self.st_ctime
342+
if isinstance(ctime, float):
343+
return int(ctime * 1e9)
344+
return ctime * 1_000_000_000
345+
310346
# --- extra fields ------------------------------------------------
311347

312348
def __getattr__(self, item):

upath/core.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ def _raise_unsupported(cls_name: str, method: str) -> NoReturn:
116116
raise UnsupportedOperation(f"{cls_name}.{method}() is unsupported")
117117

118118

119+
class _IncompatibleProtocolError(TypeError, ValueError):
120+
"""switch to TypeError for incompatible protocols in a backward compatible way.
121+
122+
!!! Do not use this exception directly !!!
123+
"""
124+
125+
119126
class _UPathMeta(ABCMeta):
120127
"""metaclass for UPath to customize instance creation
121128
@@ -419,11 +426,16 @@ def __new__(
419426
protocol = storage_options.pop("scheme")
420427

421428
# determine the protocol
422-
pth_protocol = get_upath_protocol(
423-
args[0] if args else "",
424-
protocol=protocol,
425-
storage_options=storage_options,
426-
)
429+
try:
430+
pth_protocol = get_upath_protocol(
431+
args[0] if args else "",
432+
protocol=protocol,
433+
storage_options=storage_options,
434+
)
435+
except ValueError as e:
436+
if "incompatible with" in str(e):
437+
raise _IncompatibleProtocolError(str(e)) from e
438+
raise
427439
# determine which UPath subclass to dispatch to
428440
upath_cls: type[UPath] | None
429441
if cls._protocol_dispatch or cls._protocol_dispatch is None:
@@ -1257,6 +1269,14 @@ def move_into(
12571269
target = self.with_segments(target_dir, name)
12581270
return self.move(target)
12591271

1272+
def _copy_from(
1273+
self,
1274+
source: ReadablePath,
1275+
follow_symlinks: bool = True,
1276+
**kwargs: Any,
1277+
) -> None:
1278+
return super()._copy_from(source, follow_symlinks)
1279+
12601280
# --- WritablePath attributes -------------------------------------
12611281

12621282
def symlink_to(

upath/extensions.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -284,22 +284,37 @@ def is_absolute(self) -> bool:
284284
return self.__wrapped__.is_absolute()
285285

286286
def __eq__(self, other: object) -> bool:
287-
return self.__wrapped__.__eq__(other)
287+
if not isinstance(other, type(self)):
288+
return NotImplemented
289+
return self.__wrapped__.__eq__(other.__wrapped__)
288290

289291
def __hash__(self) -> int:
290292
return self.__wrapped__.__hash__()
291293

294+
def __ne__(self, other: object) -> bool:
295+
if not isinstance(other, type(self)):
296+
return NotImplemented
297+
return self.__wrapped__.__ne__(other.__wrapped__)
298+
292299
def __lt__(self, other: object) -> bool:
293-
return self.__wrapped__.__lt__(other)
300+
if not isinstance(other, type(self)):
301+
return NotImplemented
302+
return self.__wrapped__.__lt__(other.__wrapped__)
294303

295304
def __le__(self, other: object) -> bool:
296-
return self.__wrapped__.__le__(other)
305+
if not isinstance(other, type(self)):
306+
return NotImplemented
307+
return self.__wrapped__.__le__(other.__wrapped__)
297308

298309
def __gt__(self, other: object) -> bool:
299-
return self.__wrapped__.__gt__(other)
310+
if not isinstance(other, type(self)):
311+
return NotImplemented
312+
return self.__wrapped__.__gt__(other.__wrapped__)
300313

301314
def __ge__(self, other: object) -> bool:
302-
return self.__wrapped__.__ge__(other)
315+
if not isinstance(other, type(self)):
316+
return NotImplemented
317+
return self.__wrapped__.__ge__(other.__wrapped__)
303318

304319
def resolve(self, strict: bool = False) -> Self:
305320
return self._from_upath(self.__wrapped__.resolve(strict=strict))
@@ -313,8 +328,11 @@ def lchmod(self, mode: int) -> None:
313328
def unlink(self, missing_ok: bool = False) -> None:
314329
self.__wrapped__.unlink(missing_ok=missing_ok)
315330

316-
def rmdir(self, recursive: bool = True) -> None: # fixme: non-standard
317-
self.__wrapped__.rmdir(recursive=recursive)
331+
def rmdir(self, recursive: bool = UNSET_DEFAULT) -> None: # fixme: non-standard
332+
kwargs: dict[str, Any] = {}
333+
if recursive is not UNSET_DEFAULT:
334+
kwargs["recursive"] = recursive
335+
self.__wrapped__.rmdir(**kwargs)
318336

319337
def rename(
320338
self,
@@ -324,9 +342,14 @@ def rename(
324342
maxdepth: int | None = UNSET_DEFAULT,
325343
**kwargs: Any,
326344
) -> Self:
345+
if recursive is not UNSET_DEFAULT:
346+
kwargs["recursive"] = recursive
347+
if maxdepth is not UNSET_DEFAULT:
348+
kwargs["maxdepth"] = maxdepth
327349
return self._from_upath(
328350
self.__wrapped__.rename(
329-
target, recursive=recursive, maxdepth=maxdepth, **kwargs
351+
target.__wrapped__ if isinstance(target, ProxyUPath) else target,
352+
**kwargs,
330353
)
331354
)
332355

upath/implementations/local.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import pathlib
5+
import shutil
56
import sys
67
import warnings
78
from collections.abc import Iterator
@@ -196,6 +197,33 @@ def __vfspath__(self) -> str:
196197
def __open_reader__(self) -> BinaryIO:
197198
return self.open("rb")
198199

200+
def __eq__(self, other: object) -> bool:
201+
if not isinstance(other, UPath):
202+
return NotImplemented
203+
eq_path = super().__eq__(other)
204+
if eq_path is NotImplemented:
205+
return NotImplemented
206+
return (
207+
eq_path
208+
and self.protocol == other.protocol
209+
and self.storage_options == other.storage_options
210+
)
211+
212+
def __ne__(self, other: object) -> bool:
213+
if not isinstance(other, UPath):
214+
return NotImplemented
215+
ne_path = super().__ne__(other)
216+
if ne_path is NotImplemented:
217+
return NotImplemented
218+
return (
219+
ne_path
220+
or self.protocol != other.protocol
221+
or self.storage_options != other.storage_options
222+
)
223+
224+
def __hash__(self) -> int:
225+
return super().__hash__()
226+
199227
if sys.version_info >= (3, 14):
200228

201229
def __open_rb__(self, buffering: int = UNSET_DEFAULT) -> BinaryIO:
@@ -316,6 +344,12 @@ def open(
316344
**fsspec_kwargs,
317345
)
318346

347+
def rmdir(self, recursive: bool = UNSET_DEFAULT) -> None:
348+
if recursive is UNSET_DEFAULT or not recursive:
349+
return super().rmdir()
350+
else:
351+
shutil.rmtree(self)
352+
319353
if sys.version_info < (3, 14): # noqa: C901
320354

321355
@overload
@@ -656,7 +690,10 @@ def chmod(
656690
if not hasattr(pathlib.Path, "_copy_from"):
657691

658692
def _copy_from(
659-
self, source: ReadablePath | LocalPath, follow_symlinks: bool = True
693+
self,
694+
source: ReadablePath | LocalPath,
695+
follow_symlinks: bool = True,
696+
preserve_metadata: bool = False,
660697
) -> None:
661698
_copy_from: Any = WritablePath._copy_from.__get__(self)
662699
_copy_from(source, follow_symlinks=follow_symlinks)

upath/tests/cases.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
from fsspec import filesystem
1111
from packaging.version import Version
1212

13+
from upath import UnsupportedOperation
1314
from upath import UPath
1415
from upath._stat import UPathStatResult
16+
from upath.types import StatResultType
1517

1618

1719
class BaseTests:
@@ -29,7 +31,12 @@ def test_home(self):
2931

3032
def test_stat(self):
3133
stat = self.path.stat()
32-
assert isinstance(stat, UPathStatResult)
34+
35+
# for debugging os.stat_result compatibility
36+
attrs = {attr for attr in dir(stat) if attr.startswith("st_")}
37+
print(attrs)
38+
39+
assert isinstance(stat, StatResultType)
3340
assert len(tuple(stat)) == os.stat_result.n_sequence_fields
3441

3542
with warnings.catch_warnings():
@@ -117,7 +124,12 @@ def test_is_absolute(self):
117124
assert self.path.is_absolute() is True
118125

119126
def test_is_mount(self):
120-
assert self.path.is_mount() is False
127+
try:
128+
self.path.is_mount()
129+
except UnsupportedOperation:
130+
pytest.skip(f"is_mount() not supported for {type(self.path).__name__}")
131+
else:
132+
assert self.path.is_mount() is False
121133

122134
def test_is_symlink(self):
123135
assert self.path.is_symlink() is False
@@ -175,8 +187,10 @@ def test_parents(self):
175187
assert p.parents[1].name == self.path.name
176188

177189
def test_lchmod(self):
178-
with pytest.raises(NotImplementedError):
179-
self.path.lchmod(mode=77)
190+
try:
191+
self.path.lchmod(mode=0o777)
192+
except UnsupportedOperation:
193+
pass
180194

181195
def test_lstat(self):
182196
with pytest.warns(UserWarning, match=r"[A-Za-z]+.stat"):
@@ -540,14 +554,16 @@ def test_hashable(self):
540554
assert hash(self.path)
541555

542556
def test_storage_options_dont_affect_hash(self):
543-
p0 = UPath(str(self.path), test_extra=1, **self.path.storage_options)
544-
p1 = UPath(str(self.path), test_extra=2, **self.path.storage_options)
557+
cls = type(self.path)
558+
p0 = cls(str(self.path), test_extra=1, **self.path.storage_options)
559+
p1 = cls(str(self.path), test_extra=2, **self.path.storage_options)
545560
assert hash(p0) == hash(p1)
546561

547562
def test_eq(self):
548-
p0 = UPath(str(self.path), test_extra=1, **self.path.storage_options)
549-
p1 = UPath(str(self.path), test_extra=1, **self.path.storage_options)
550-
p2 = UPath(str(self.path), test_extra=2, **self.path.storage_options)
563+
cls = type(self.path)
564+
p0 = cls(str(self.path), test_extra=1, **self.path.storage_options)
565+
p1 = cls(str(self.path), test_extra=1, **self.path.storage_options)
566+
p2 = cls(str(self.path), test_extra=2, **self.path.storage_options)
551567
assert p0 == p1
552568
assert p0 != p2
553569
assert p1 != p2

upath/tests/test_extensions.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
import os
2+
import sys
3+
14
import pytest
25

6+
from upath import UnsupportedOperation
37
from upath import UPath
48
from upath.extensions import ProxyUPath
59
from upath.implementations.local import FilePath
10+
from upath.implementations.local import PosixUPath
11+
from upath.implementations.local import WindowsUPath
612
from upath.implementations.memory import MemoryPath
713
from upath.tests.cases import BaseTests
814

@@ -38,6 +44,64 @@ def test_chmod(self):
3844
self.path.joinpath("file1.txt").chmod(777)
3945

4046

47+
class TestProxyPathlibPath(BaseTests):
48+
@pytest.fixture(autouse=True)
49+
def path(self, local_testdir):
50+
self.path = ProxyUPath(f"{local_testdir}")
51+
self.prepare_file_system()
52+
53+
def test_is_ProxyUPath(self):
54+
assert isinstance(self.path, ProxyUPath)
55+
56+
def test_is_not_PosixUPath_WindowsUPath(self):
57+
assert not isinstance(self.path, (PosixUPath, WindowsUPath))
58+
59+
def test_chmod(self):
60+
self.path.joinpath("file1.txt").chmod(777)
61+
62+
@pytest.mark.skipif(
63+
sys.version_info < (3, 12), reason="storage options only handled in 3.12+"
64+
)
65+
def test_eq(self):
66+
super().test_eq()
67+
68+
def test_group(self):
69+
pytest.importorskip("grp")
70+
self.path.group()
71+
72+
def test_owner(self):
73+
pytest.importorskip("pwd")
74+
self.path.owner()
75+
76+
def test_readlink(self):
77+
try:
78+
os.readlink
79+
except AttributeError:
80+
pytest.skip("os.readlink not available on this platform")
81+
with pytest.raises((OSError, UnsupportedOperation)):
82+
self.path.readlink()
83+
84+
def test_protocol(self):
85+
assert self.path.protocol == ""
86+
87+
def test_as_uri(self):
88+
assert self.path.as_uri().startswith("file://")
89+
90+
@pytest.mark.xfail(reason="need to revisit relative path .rename")
91+
def test_rename2(self):
92+
super().test_rename2()
93+
94+
def test_lstat(self):
95+
st = self.path.lstat()
96+
assert st is not None
97+
98+
def test_relative_to(self):
99+
base = self.path
100+
child = self.path / "folder1" / "file1.txt"
101+
relative = child.relative_to(base)
102+
assert str(relative) == f"folder1{os.sep}file1.txt"
103+
104+
41105
def test_custom_subclass():
42106

43107
class ReversePath(ProxyUPath):

0 commit comments

Comments
 (0)