Skip to content

Commit 5f5e054

Browse files
authored
Add Python 3.13 compatibility (#474)
* Py 313 compatibility * type ignore * Update CI versions * Fix version checks * Add tests for new methods * mypy fix * Right mypy line * Keep 3.8 for now * Keep as native paths until last chance * Allow pathlike for glob and rglob * fix tests * Pass on <py3.13 * update history note * Code review comments * omit glob.py from coverage * reword history * Add NotImplementedError for the missing methods in the CloudPath class
1 parent 68774bf commit 5f5e054

File tree

17 files changed

+371
-67
lines changed

17 files changed

+371
-67
lines changed

.github/workflows/docs-master.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- name: Set up Python
1717
uses: actions/setup-python@v4
1818
with:
19-
python-version: 3.8
19+
python-version: 3.11
2020
cache: "pip" # caching pip dependencies
2121
cache-dependency-path: |
2222
pyproject.toml

.github/workflows/docs-preview.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
- name: Set up Python
1515
uses: actions/setup-python@v4
1616
with:
17-
python-version: 3.8
17+
python-version: 3.11
1818
cache: "pip" # caching pip dependencies
1919
cache-dependency-path: |
2020
pyproject.toml

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- name: Set up Python
1717
uses: actions/setup-python@v4
1818
with:
19-
python-version: 3.8
19+
python-version: 3.11
2020

2121
- name: Install dependencies
2222
run: |

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
strategy:
3838
matrix:
3939
os: [ubuntu-latest, macos-latest, windows-latest]
40-
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
40+
python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
4141

4242
steps:
4343
- uses: actions/checkout@v4

HISTORY.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@
44

55
- Added support for custom schemes in CloudPath and Client subclases. (Issue [#466](https://github.com/drivendataorg/cloudpathlib/issues/466), PR [#467](https://github.com/drivendataorg/cloudpathlib/pull/467))
66
- Fixed `ResourceNotFoundError` on Azure gen2 storage accounts with HNS enabled and issue that some Azure credentials do not have `account_name`. (Issue [#470](https://github.com/drivendataorg/cloudpathlib/issues/470), Issue [#476](https://github.com/drivendataorg/cloudpathlib/issues/476), PR [#478](https://github.com/drivendataorg/cloudpathlib/pull/478))
7+
- Added support for Python 3.13 (Issue [#472](https://github.com/drivendataorg/cloudpathlib/issues/472), [PR #474](https://github.com/drivendataorg/cloudpathlib/pull/474)):
8+
- [`.full_match` added](https://docs.python.org/3.13/library/pathlib.html#pathlib.PurePath.full_match)
9+
- [`.from_uri` added](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.from_uri)
10+
- [`follow_symlinks` kwarg added to `is_file`](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.is_file) added as no-op
11+
- [`follow_symlinks` kwarg added to `is_dir`](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.is_dir) added as no-op
12+
- [`newline` kwarg added to `read_text`](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.read_text)
13+
- [`recurse_symlinks` kwarg added to `glob`](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.glob) added as no-op
14+
- [`pattern` parameter for `glob` can be PathLike](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.glob)
15+
- [`recurse_symlinks` kwarg added to `rglob`](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.rglob) added as no-op
16+
- [`pattern` parameter for `rglob` can be PathLike](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.rglob)
17+
- [`.parser` property added](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.parser)
18+
719

820
## v0.19.0 (2024-08-29)
921

cloudpathlib/azure/azblobpath.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,6 @@ class AzureBlobPath(CloudPath):
3939
def drive(self) -> str:
4040
return self.container
4141

42-
def is_dir(self) -> bool:
43-
return self.client._is_file_or_dir(self) == "dir"
44-
45-
def is_file(self) -> bool:
46-
return self.client._is_file_or_dir(self) == "file"
47-
4842
def mkdir(self, parents=False, exist_ok=False):
4943
self.client._mkdir(self, parents=parents, exist_ok=exist_ok)
5044

cloudpathlib/cloudpath.py

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
PosixPath,
1010
PurePosixPath,
1111
WindowsPath,
12-
_PathParents,
1312
)
1413

1514
import shutil
1615
import sys
16+
from types import MethodType
1717
from typing import (
1818
BinaryIO,
1919
Literal,
@@ -56,21 +56,29 @@
5656
else:
5757
from typing_extensions import Self
5858

59-
if sys.version_info >= (3, 12):
59+
60+
if sys.version_info < (3, 12):
61+
from pathlib import _posix_flavour # type: ignore[attr-defined] # noqa: F811
62+
from pathlib import _make_selector as _make_selector_pathlib # type: ignore[attr-defined] # noqa: F811
63+
from pathlib import _PathParents # type: ignore[attr-defined]
64+
65+
def _make_selector(pattern_parts, _flavour, case_sensitive=True): # noqa: F811
66+
return _make_selector_pathlib(tuple(pattern_parts), _flavour)
67+
68+
elif sys.version_info[:2] == (3, 12):
69+
from pathlib import _PathParents # type: ignore[attr-defined]
6070
from pathlib import posixpath as _posix_flavour # type: ignore[attr-defined]
6171
from pathlib import _make_selector # type: ignore[attr-defined]
62-
else:
63-
from pathlib import _posix_flavour # type: ignore[attr-defined]
64-
from pathlib import _make_selector as _make_selector_pathlib # type: ignore[attr-defined]
72+
elif sys.version_info >= (3, 13):
73+
from pathlib._local import _PathParents
74+
import posixpath as _posix_flavour # type: ignore[attr-defined] # noqa: F811
6575

66-
def _make_selector(pattern_parts, _flavour, case_sensitive=True):
67-
return _make_selector_pathlib(tuple(pattern_parts), _flavour)
76+
from .legacy.glob import _make_selector # noqa: F811
6877

6978

7079
from cloudpathlib.enums import FileCacheMode
7180

7281
from . import anypath
73-
7482
from .exceptions import (
7583
ClientMismatchError,
7684
CloudPathFileExistsError,
@@ -194,7 +202,12 @@ def __init__(cls, name: str, bases: Tuple[type, ...], dic: Dict[str, Any]) -> No
194202
and getattr(getattr(Path, attr), "__doc__", None)
195203
):
196204
docstring = getattr(Path, attr).__doc__ + " _(Docstring copied from pathlib.Path)_"
197-
getattr(cls, attr).__doc__ = docstring
205+
206+
if isinstance(getattr(cls, attr), (MethodType)):
207+
getattr(cls, attr).__func__.__doc__ = docstring
208+
else:
209+
getattr(cls, attr).__doc__ = docstring
210+
198211
if isinstance(getattr(cls, attr), property):
199212
# Properties have __doc__ duplicated under fget, and at least some parsers
200213
# read it from there.
@@ -383,16 +396,6 @@ def drive(self) -> str:
383396
"""For example "bucket" on S3 or "container" on Azure; needs to be defined for each class"""
384397
pass
385398

386-
@abc.abstractmethod
387-
def is_dir(self) -> bool:
388-
"""Should be implemented without requiring a dir is downloaded"""
389-
pass
390-
391-
@abc.abstractmethod
392-
def is_file(self) -> bool:
393-
"""Should be implemented without requiring that the file is downloaded"""
394-
pass
395-
396399
@abc.abstractmethod
397400
def mkdir(self, parents: bool = False, exist_ok: bool = False) -> None:
398401
"""Should be implemented using the client API without requiring a dir is downloaded"""
@@ -427,24 +430,44 @@ def as_uri(self) -> str:
427430
def exists(self) -> bool:
428431
return self.client._exists(self)
429432

433+
def is_dir(self, follow_symlinks=True) -> bool:
434+
return self.client._is_file_or_dir(self) == "dir"
435+
436+
def is_file(self, follow_symlinks=True) -> bool:
437+
return self.client._is_file_or_dir(self) == "file"
438+
430439
@property
431440
def fspath(self) -> str:
432441
return self.__fspath__()
433442

434-
def _glob_checks(self, pattern: str) -> None:
435-
if ".." in pattern:
443+
@classmethod
444+
def from_uri(cls, uri: str) -> Self:
445+
return cls(uri)
446+
447+
def _glob_checks(self, pattern: Union[str, os.PathLike]) -> str:
448+
if isinstance(pattern, os.PathLike):
449+
if isinstance(pattern, CloudPath):
450+
str_pattern = str(pattern.relative_to(self))
451+
else:
452+
str_pattern = os.fspath(pattern)
453+
else:
454+
str_pattern = str(pattern)
455+
456+
if ".." in str_pattern:
436457
raise CloudPathNotImplementedError(
437458
"Relative paths with '..' not supported in glob patterns."
438459
)
439460

440-
if pattern.startswith(self.cloud_prefix) or pattern.startswith("/"):
461+
if str_pattern.startswith(self.cloud_prefix) or str_pattern.startswith("/"):
441462
raise CloudPathNotImplementedError("Non-relative patterns are unsupported")
442463

443464
if self.drive == "":
444465
raise CloudPathNotImplementedError(
445466
".glob is only supported within a bucket or container; you can use `.iterdir` to list buckets; for example, CloudPath('s3://').iterdir()"
446467
)
447468

469+
return str_pattern
470+
448471
def _build_subtree(self, recursive):
449472
# build a tree structure for all files out of default dicts
450473
Tree: Callable = lambda: defaultdict(Tree)
@@ -488,9 +511,9 @@ def _glob(self, selector, recursive: bool) -> Generator[Self, None, None]:
488511
yield (self / str(p)[len(self.name) + 1 :])
489512

490513
def glob(
491-
self, pattern: str, case_sensitive: Optional[bool] = None
514+
self, pattern: Union[str, os.PathLike], case_sensitive: Optional[bool] = None
492515
) -> Generator[Self, None, None]:
493-
self._glob_checks(pattern)
516+
pattern = self._glob_checks(pattern)
494517

495518
pattern_parts = PurePosixPath(pattern).parts
496519
selector = _make_selector(
@@ -505,9 +528,9 @@ def glob(
505528
)
506529

507530
def rglob(
508-
self, pattern: str, case_sensitive: Optional[bool] = None
531+
self, pattern: Union[str, os.PathLike], case_sensitive: Optional[bool] = None
509532
) -> Generator[Self, None, None]:
510-
self._glob_checks(pattern)
533+
pattern = self._glob_checks(pattern)
511534

512535
pattern_parts = PurePosixPath(pattern).parts
513536
selector = _make_selector(
@@ -812,8 +835,13 @@ def read_bytes(self) -> bytes:
812835
with self.open(mode="rb") as f:
813836
return f.read()
814837

815-
def read_text(self, encoding: Optional[str] = None, errors: Optional[str] = None) -> str:
816-
with self.open(mode="r", encoding=encoding, errors=errors) as f:
838+
def read_text(
839+
self,
840+
encoding: Optional[str] = None,
841+
errors: Optional[str] = None,
842+
newline: Optional[str] = None,
843+
) -> str:
844+
with self.open(mode="r", encoding=encoding, errors=errors, newline=newline) as f:
817845
return f.read()
818846

819847
def is_junction(self):
@@ -904,6 +932,19 @@ def is_relative_to(self, other: Self) -> bool:
904932
def name(self) -> str:
905933
return self._dispatch_to_path("name")
906934

935+
def full_match(self, pattern: str, case_sensitive: Optional[bool] = None) -> bool:
936+
if sys.version_info < (3, 13):
937+
raise NotImplementedError("full_match requires Python 3.13 or higher")
938+
939+
# strip scheme from start of pattern before testing
940+
if pattern.startswith(self.anchor + self.drive):
941+
pattern = pattern[len(self.anchor + self.drive) :]
942+
943+
# remove drive, which is kept on normal dispatch to pathlib
944+
return PurePosixPath(self._no_prefix_no_drive).full_match( # type: ignore[attr-defined]
945+
pattern, case_sensitive=case_sensitive
946+
)
947+
907948
def match(self, path_pattern: str, case_sensitive: Optional[bool] = None) -> bool:
908949
# strip scheme from start of pattern before testing
909950
if path_pattern.startswith(self.anchor + self.drive + "/"):
@@ -916,6 +957,13 @@ def match(self, path_pattern: str, case_sensitive: Optional[bool] = None) -> boo
916957

917958
return self._dispatch_to_path("match", path_pattern, **kwargs)
918959

960+
@property
961+
def parser(self) -> Self:
962+
if sys.version_info < (3, 13):
963+
raise NotImplementedError("parser requires Python 3.13 or higher")
964+
965+
return self._dispatch_to_path("parser")
966+
919967
@property
920968
def parent(self) -> Self:
921969
return self._dispatch_to_path("parent")

cloudpathlib/gs/gspath.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,6 @@ class GSPath(CloudPath):
3232
def drive(self) -> str:
3333
return self.bucket
3434

35-
def is_dir(self) -> bool:
36-
return self.client._is_file_or_dir(self) == "dir"
37-
38-
def is_file(self) -> bool:
39-
return self.client._is_file_or_dir(self) == "file"
40-
4135
def mkdir(self, parents=False, exist_ok=False):
4236
# not possible to make empty directory on cloud storage
4337
pass

0 commit comments

Comments
 (0)