Skip to content

Commit 0716d42

Browse files
authored
Backport types and methods to local (#421)
* upath.implementations.local: fix annotations and backport methods * tests: test cases for .copy and .copy_into * tests: fix test case crosspolution due to memory fs * upath: fix py3.14+ discrepancy between __open_reader__ and __open_rb__
1 parent 0d8ed38 commit 0716d42

File tree

5 files changed

+300
-2
lines changed

5 files changed

+300
-2
lines changed

upath/core.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,11 @@ def iterdir(self) -> Iterator[Self]:
614614
def __open_reader__(self) -> BinaryIO:
615615
return self.fs.open(self.path, mode="rb")
616616

617+
if sys.version_info >= (3, 14):
618+
619+
def __open_rb__(self, buffering: int = UNSET_DEFAULT) -> BinaryIO:
620+
return self.open("rb", buffering=buffering)
621+
617622
def readlink(self) -> Self:
618623
_raise_unsupported(type(self).__name__, "readlink")
619624

upath/implementations/local.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from collections.abc import Sequence
99
from typing import TYPE_CHECKING
1010
from typing import Any
11+
from typing import Callable
12+
from typing import Literal
13+
from typing import overload
1114
from urllib.parse import SplitResult
1215

1316
from fsspec import AbstractFileSystem
@@ -19,14 +22,27 @@
1922
from upath._protocol import compatible_protocol
2023
from upath.core import UPath
2124
from upath.core import _UPathMixin
25+
from upath.types import UNSET_DEFAULT
2226
from upath.types import JoinablePathLike
27+
from upath.types import PathInfo
28+
from upath.types import ReadablePath
29+
from upath.types import ReadablePathLike
30+
from upath.types import SupportsPathLike
31+
from upath.types import WritablePath
2332

2433
if TYPE_CHECKING:
34+
from typing import IO
35+
from typing import BinaryIO
36+
from typing import TextIO
37+
from typing import TypeVar
38+
2539
if sys.version_info >= (3, 11):
2640
from typing import Self
2741
else:
2842
from typing_extensions import Self
2943

44+
_WT = TypeVar("_WT", bound="WritablePath")
45+
3046
__all__ = [
3147
"LocalPath",
3248
"PosixUPath",
@@ -66,6 +82,27 @@ def _warn_protocol_storage_options(
6682
)
6783

6884

85+
class _LocalPathInfo(PathInfo):
86+
"""Backported PathInfo implementation for LocalPath.
87+
todo: currently not handling symlinks correctly.
88+
"""
89+
90+
def __init__(self, path: LocalPath) -> None:
91+
self._path = path.path
92+
93+
def exists(self, *, follow_symlinks: bool = True) -> bool:
94+
return os.path.exists(self._path)
95+
96+
def is_dir(self, *, follow_symlinks: bool = True) -> bool:
97+
return os.path.isdir(self._path)
98+
99+
def is_file(self, *, follow_symlinks: bool = True) -> bool:
100+
return os.path.isfile(self._path)
101+
102+
def is_symlink(self) -> bool:
103+
return os.path.islink(self._path)
104+
105+
69106
class LocalPath(_UPathMixin, pathlib.Path):
70107
__slots__ = (
71108
"_chain",
@@ -147,6 +184,27 @@ def _init(self, **kwargs: Any) -> None:
147184
super()._init(**kwargs) # type: ignore[misc]
148185
self._chain = Chain(ChainSegment(str(self), "", {}))
149186

187+
def __vfspath__(self) -> str:
188+
return self.__fspath__()
189+
190+
def __open_reader__(self) -> BinaryIO:
191+
return self.open("rb")
192+
193+
if sys.version_info >= (3, 14):
194+
195+
def __open_rb__(self, buffering: int = UNSET_DEFAULT) -> BinaryIO:
196+
return self.open("rb", buffering=buffering)
197+
198+
def __open_writer__(self, mode: Literal["a", "w", "x"]) -> BinaryIO:
199+
if mode == "w":
200+
return self.open(mode="wb")
201+
elif mode == "a":
202+
return self.open(mode="ab")
203+
elif mode == "x":
204+
return self.open(mode="xb")
205+
else:
206+
raise ValueError(f"invalid mode: {mode}")
207+
150208
def with_segments(self, *pathsegments: str | os.PathLike[str]) -> Self:
151209
return type(self)(
152210
*pathsegments,
@@ -190,6 +248,149 @@ def __rtruediv__(self, other) -> Self:
190248
else other
191249
)
192250

251+
@overload # type: ignore[override]
252+
def open(
253+
self,
254+
mode: Literal["r", "w", "a"] = "r",
255+
buffering: int = ...,
256+
encoding: str = ...,
257+
errors: str = ...,
258+
newline: str = ...,
259+
**fsspec_kwargs: Any,
260+
) -> TextIO: ...
261+
262+
@overload
263+
def open(
264+
self,
265+
mode: Literal["rb", "wb", "ab", "xb"],
266+
buffering: int = ...,
267+
encoding: str = ...,
268+
errors: str = ...,
269+
newline: str = ...,
270+
**fsspec_kwargs: Any,
271+
) -> BinaryIO: ...
272+
273+
@overload
274+
def open(
275+
self,
276+
mode: str,
277+
buffering: int = ...,
278+
encoding: str | None = ...,
279+
errors: str | None = ...,
280+
newline: str | None = ...,
281+
**fsspec_kwargs: Any,
282+
) -> IO[Any]: ...
283+
284+
def open(
285+
self,
286+
mode: str = "r",
287+
buffering: int = UNSET_DEFAULT,
288+
encoding: str | None = UNSET_DEFAULT,
289+
errors: str | None = UNSET_DEFAULT,
290+
newline: str | None = UNSET_DEFAULT,
291+
**fsspec_kwargs: Any,
292+
) -> IO[Any]:
293+
if not fsspec_kwargs:
294+
kwargs: dict[str, str | int | None] = {}
295+
if buffering is not UNSET_DEFAULT:
296+
kwargs["buffering"] = buffering
297+
if encoding is not UNSET_DEFAULT:
298+
kwargs["encoding"] = encoding
299+
if errors is not UNSET_DEFAULT:
300+
kwargs["errors"] = errors
301+
if newline is not UNSET_DEFAULT:
302+
kwargs["newline"] = newline
303+
return super().open(mode, **kwargs) # type: ignore # noqa: E501
304+
return UPath.open.__get__(self)(
305+
mode,
306+
buffering=buffering,
307+
encoding=encoding,
308+
errors=errors,
309+
newline=newline,
310+
**fsspec_kwargs,
311+
)
312+
313+
if sys.version_info < (3, 14):
314+
315+
@overload
316+
def copy(self, target: _WT, **kwargs: Any) -> _WT: ...
317+
318+
@overload
319+
def copy(self, target: SupportsPathLike | str, **kwargs: Any) -> Self: ...
320+
321+
def copy(
322+
self, target: _WT | SupportsPathLike | str, **kwargs: Any
323+
) -> _WT | Self:
324+
# hacky workaround for missing pathlib.Path.copy in python < 3.14
325+
# todo: revisit
326+
_copy: Any = ReadablePath.copy.__get__(self)
327+
if not isinstance(target, UPath):
328+
return _copy(self.with_segments(str(target)), **kwargs)
329+
else:
330+
return _copy(target, **kwargs)
331+
332+
@overload
333+
def copy_into(self, target_dir: _WT, **kwargs: Any) -> _WT: ...
334+
335+
@overload
336+
def copy_into(
337+
self, target_dir: SupportsPathLike | str, **kwargs: Any
338+
) -> Self: ...
339+
340+
def copy_into(
341+
self,
342+
target_dir: _WT | SupportsPathLike | str,
343+
**kwargs: Any,
344+
) -> _WT | Self:
345+
# hacky workaround for missing pathlib.Path.copy_into in python < 3.14
346+
# todo: revisit
347+
_copy_into: Any = ReadablePath.copy_into.__get__(self)
348+
if not isinstance(target_dir, UPath):
349+
return _copy_into(self.with_segments(str(target_dir)), **kwargs)
350+
else:
351+
return _copy_into(target_dir, **kwargs)
352+
353+
@property
354+
def info(self) -> PathInfo:
355+
return _LocalPathInfo(self)
356+
357+
if sys.version_info < (3, 13):
358+
359+
def full_match(self, pattern: str) -> bool:
360+
# hacky workaround for missing pathlib.Path.full_match in python < 3.13
361+
# todo: revisit
362+
return self.match(pattern)
363+
364+
if sys.version_info < (3, 12):
365+
366+
def is_junction(self) -> bool:
367+
return False
368+
369+
def walk(
370+
self,
371+
top_down: bool = True,
372+
on_error: Callable[[Exception], Any] | None = None,
373+
follow_symlinks: bool = False,
374+
) -> Iterator[tuple[Self, list[str], list[str]]]:
375+
_walk = ReadablePath.walk.__get__(self)
376+
return _walk(top_down, on_error, follow_symlinks)
377+
378+
if sys.version_info < (3, 10):
379+
380+
def hardlink_to(self, target: ReadablePathLike) -> None:
381+
try:
382+
os.link(target, self)
383+
except AttributeError:
384+
raise NotImplementedError
385+
386+
if not hasattr(pathlib.Path, "_copy_from"):
387+
388+
def _copy_from(
389+
self, source: ReadablePath | LocalPath, follow_symlinks: bool = True
390+
) -> None:
391+
_copy_from: Any = WritablePath._copy_from.__get__(self)
392+
_copy_from(source, follow_symlinks=follow_symlinks)
393+
193394

194395
UPath.register(LocalPath)
195396

upath/tests/cases.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,3 +567,42 @@ def test_info(self):
567567
assert p1.info.is_file() is False
568568
assert p1.info.is_dir() is True
569569
assert p1.info.is_symlink() is False
570+
571+
def test_copy_local(self, tmp_path: Path):
572+
target = UPath(tmp_path) / "target-file1.txt"
573+
574+
source = self.path / "file1.txt"
575+
content = source.read_text()
576+
source.copy(target)
577+
assert target.exists()
578+
assert target.read_text() == content
579+
580+
def test_copy_into_local(self, tmp_path: Path):
581+
target_dir = UPath(tmp_path) / "target-dir"
582+
target_dir.mkdir()
583+
584+
source = self.path / "file1.txt"
585+
content = source.read_text()
586+
source.copy_into(target_dir)
587+
target = target_dir / "file1.txt"
588+
assert target.exists()
589+
assert target.read_text() == content
590+
591+
def test_copy_memory(self, clear_fsspec_memory_cache):
592+
target = UPath("memory:///target-file1.txt")
593+
source = self.path / "file1.txt"
594+
content = source.read_text()
595+
source.copy(target)
596+
assert target.exists()
597+
assert target.read_text() == content
598+
599+
def test_copy_into_memory(self, clear_fsspec_memory_cache):
600+
target_dir = UPath("memory:///target-dir")
601+
target_dir.mkdir()
602+
603+
source = self.path / "file1.txt"
604+
content = source.read_text()
605+
source.copy_into(target_dir)
606+
target = target_dir / "file1.txt"
607+
assert target.exists()
608+
assert target.read_text() == content

upath/tests/conftest.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import fsspec
1212
import pytest
13+
from fsspec import get_filesystem_class
1314
from fsspec.implementations.local import LocalFileSystem
1415
from fsspec.implementations.local import make_path_posix
1516
from fsspec.implementations.smb import SMBFileSystem
@@ -47,6 +48,18 @@ def clear_registry():
4748
_registry.clear()
4849

4950

51+
@pytest.fixture(scope="function")
52+
def clear_fsspec_memory_cache():
53+
fs_cls = get_filesystem_class("memory")
54+
pseudo_dirs = fs_cls.pseudo_dirs.copy()
55+
store = fs_cls.store.copy()
56+
try:
57+
yield
58+
finally:
59+
fs_cls.pseudo_dirs = pseudo_dirs
60+
fs_cls.store = store
61+
62+
5063
@pytest.fixture(scope="function")
5164
def local_testdir(tmp_path, clear_registry):
5265
folder1 = tmp_path.joinpath("folder1")
@@ -257,10 +270,10 @@ def http_server(tmp_path_factory):
257270
requests = pytest.importorskip("requests")
258271
pytest.importorskip("http.server")
259272
proc = subprocess.Popen(
260-
shlex.split(f"python -m http.server --directory {http_tempdir} 8080")
273+
shlex.split(f"python -m http.server --directory {http_tempdir} 18080")
261274
)
262275
try:
263-
url = "http://127.0.0.1:8080/folder"
276+
url = "http://127.0.0.1:18080/folder"
264277
path = Path(http_tempdir) / "folder"
265278
path.mkdir()
266279
timeout = 10

upath/tests/implementations/test_data.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,43 @@ def test_info(self):
224224
assert p0.info.is_file() is True
225225
assert p0.info.is_dir() is False
226226
assert p0.info.is_symlink() is False
227+
228+
def test_copy_local(self, tmp_path):
229+
target = UPath(tmp_path) / "target-file1.txt"
230+
231+
source = UPath("data:text/plain;base64,aGVsbG8gd29ybGQ=")
232+
content = source.read_text()
233+
source.copy(target)
234+
assert target.exists()
235+
assert target.read_text() == content
236+
237+
def test_copy_into_local(self, tmp_path):
238+
target_dir = UPath(tmp_path) / "target-dir"
239+
target_dir.mkdir()
240+
241+
source = UPath("data:text/plain;base64,aGVsbG8gd29ybGQ=")
242+
content = source.read_text()
243+
source.copy_into(target_dir)
244+
target = target_dir / source.name
245+
assert target.exists()
246+
assert target.read_text() == content
247+
248+
def test_copy_memory(self, clear_fsspec_memory_cache):
249+
target = UPath("memory:///target-file1.txt")
250+
251+
source = UPath("data:text/plain;base64,aGVsbG8gd29ybGQ=")
252+
content = source.read_text()
253+
source.copy(target)
254+
assert target.exists()
255+
assert target.read_text() == content
256+
257+
def test_copy_into_memory(self, clear_fsspec_memory_cache):
258+
target_dir = UPath("memory:///target-dir")
259+
target_dir.mkdir()
260+
261+
source = UPath("data:text/plain;base64,aGVsbG8gd29ybGQ=")
262+
content = source.read_text()
263+
source.copy_into(target_dir)
264+
target = target_dir / source.name
265+
assert target.exists()
266+
assert target.read_text() == content

0 commit comments

Comments
 (0)