From eb3ba8ea11ec617ff9fc8e94893f2a6f8621da70 Mon Sep 17 00:00:00 2001 From: Peter Bull Date: Mon, 9 Oct 2023 14:02:48 -0700 Subject: [PATCH] Add support for Python 3.12 (#364) * passing tests * remove print * make method signatures match * Remove unused import * ignore type errors * ignore more type errors * make linting and tests work on multiple py versions * add 3.12 to CI and pyproject * use pytest-cases fork * More performant walk implementation * format * update methods * Test walk method * Version agnostic tests * update tests * Add tests * Order agnostic walk test * Changes * Update changelog * sleep for flaky test --- .github/workflows/tests.yml | 2 +- HISTORY.md | 3 +- README.md | 5 +- cloudpathlib/cloudpath.py | 119 ++++++++++++++++++++++--- pyproject.toml | 3 +- requirements-dev.txt | 3 +- tests/performance/perf_file_listing.py | 9 ++ tests/performance/runner.py | 11 ++- tests/test_caching.py | 1 + tests/test_cloudpath_file_io.py | 72 +++++++++++++-- tests/test_cloudpath_instantiation.py | 59 ++++++++++++ tests/test_cloudpath_manipulation.py | 20 +++++ 12 files changed, 279 insertions(+), 28 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6918cf64..708bf4ad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,7 +42,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 diff --git a/HISTORY.md b/HISTORY.md index 25aee0e6..978a70be 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,9 +1,10 @@ # cloudpathlib Changelog -## UNRELEASED +## v0.16.0 (2023-10-08) - Add "CloudPath" as return type on `__init__` for mypy issues. ([Issue #179](https://github.com/drivendataorg/cloudpathlib/issues/179), [PR #342](https://github.com/drivendataorg/cloudpathlib/pull/342)) - Add `with_stem` to all path types when python version supports it (>=3.9). ([Issue #287](https://github.com/drivendataorg/cloudpathlib/issues/287), [PR #290](https://github.com/drivendataorg/cloudpathlib/pull/290), thanks to [@Gilthans](https://github.com/Gilthans)) - Add `newline` parameter to the `write_text` method to align to `pathlib` functionality as of Python 3.10. [PR #362]https://github.com/drivendataorg/cloudpathlib/pull/362), thanks to [@pricemg](https://github.com/pricemg). + - Add support for Python 3.12 ([PR #364](https://github.com/drivendataorg/cloudpathlib/pull/364)) ## v0.15.1 (2023-07-12) diff --git a/README.md b/README.md index 1343a840..ffde2073 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ Most methods and properties from `pathlib.Path` are supported except for the one | `is_absolute` | ✅ | ✅ | ✅ | | `is_dir` | ✅ | ✅ | ✅ | | `is_file` | ✅ | ✅ | ✅ | +| `is_junction` | ✅ | ✅ | ✅ | | `is_relative_to` | ✅ | ✅ | ✅ | | `iterdir` | ✅ | ✅ | ✅ | | `joinpath` | ✅ | ✅ | ✅ | @@ -160,7 +161,9 @@ Most methods and properties from `pathlib.Path` are supported except for the one | `suffixes` | ✅ | ✅ | ✅ | | `touch` | ✅ | ✅ | ✅ | | `unlink` | ✅ | ✅ | ✅ | +| `walk` | ✅ | ✅ | ✅ | | `with_name` | ✅ | ✅ | ✅ | +| `with_segments` | ✅ | ✅ | ✅ | | `with_stem` | ✅ | ✅ | ✅ | | `with_suffix` | ✅ | ✅ | ✅ | | `write_bytes` | ✅ | ✅ | ✅ | @@ -170,6 +173,7 @@ Most methods and properties from `pathlib.Path` are supported except for the one | `cwd` | ❌ | ❌ | ❌ | | `expanduser` | ❌ | ❌ | ❌ | | `group` | ❌ | ❌ | ❌ | +| `hardlink_to` | ❌ | ❌ | ❌ | | `home` | ❌ | ❌ | ❌ | | `is_block_device` | ❌ | ❌ | ❌ | | `is_char_device` | ❌ | ❌ | ❌ | @@ -179,7 +183,6 @@ Most methods and properties from `pathlib.Path` are supported except for the one | `is_socket` | ❌ | ❌ | ❌ | | `is_symlink` | ❌ | ❌ | ❌ | | `lchmod` | ❌ | ❌ | ❌ | -| `link_to` | ❌ | ❌ | ❌ | | `lstat` | ❌ | ❌ | ❌ | | `owner` | ❌ | ❌ | ❌ | | `readlink` | ❌ | ❌ | ❌ | diff --git a/cloudpathlib/cloudpath.py b/cloudpathlib/cloudpath.py index b2241b61..4e322fa0 100644 --- a/cloudpathlib/cloudpath.py +++ b/cloudpathlib/cloudpath.py @@ -8,10 +8,9 @@ PosixPath, PurePosixPath, WindowsPath, - _make_selector, - _posix_flavour, _PathParents, ) + import shutil import sys from typing import ( @@ -44,6 +43,17 @@ else: from typing_extensions import Self +if sys.version_info >= (3, 12): + from pathlib import posixpath as _posix_flavour # type: ignore[attr-defined] + from pathlib import _make_selector # type: ignore[attr-defined] +else: + from pathlib import _posix_flavour # type: ignore[attr-defined] + from pathlib import _make_selector as _make_selector_pathlib # type: ignore[attr-defined] + + def _make_selector(pattern_parts, _flavour, case_sensitive=True): + return _make_selector_pathlib(tuple(pattern_parts), _flavour) + + from cloudpathlib.enums import FileCacheMode from . import anypath @@ -342,6 +352,8 @@ def __ge__(self, other: Any) -> bool: # owner - no cloud equivalent # root - drive already has the bucket and anchor/prefix has the scheme, so nothing to store here # symlink_to - no cloud equivalent + # link_to - no cloud equivalent + # hardlink_to - no cloud equivalent # ====================== REQUIRED, NOT GENERIC ====================== # Methods that must be implemented, but have no generic application @@ -406,7 +418,7 @@ def _glob_checks(self, pattern: str) -> None: ".glob is only supported within a bucket or container; you can use `.iterdir` to list buckets; for example, CloudPath('s3://').iterdir()" ) - def _glob(self, selector, recursive: bool) -> Generator[Self, None, None]: + def _build_subtree(self, recursive): # build a tree structure for all files out of default dicts Tree: Callable = lambda: defaultdict(Tree) @@ -433,7 +445,10 @@ def _build_tree(trunk, branch, nodes, is_dir): nodes = (p for p in parts) _build_tree(file_tree, next(nodes, None), nodes, is_dir) - file_tree = dict(file_tree) # freeze as normal dict before passing in + return dict(file_tree) # freeze as normal dict before passing in + + def _glob(self, selector, recursive: bool) -> Generator[Self, None, None]: + file_tree = self._build_subtree(recursive) root = _CloudPathSelectable( self.name, @@ -445,11 +460,15 @@ def _build_tree(trunk, branch, nodes, is_dir): # select_from returns self.name/... so strip before joining yield (self / str(p)[len(self.name) + 1 :]) - def glob(self, pattern: str) -> Generator[Self, None, None]: + def glob( + self, pattern: str, case_sensitive: Optional[bool] = None + ) -> Generator[Self, None, None]: self._glob_checks(pattern) pattern_parts = PurePosixPath(pattern).parts - selector = _make_selector(tuple(pattern_parts), _posix_flavour) + selector = _make_selector( + tuple(pattern_parts), _posix_flavour, case_sensitive=case_sensitive + ) yield from self._glob( selector, @@ -458,11 +477,15 @@ def glob(self, pattern: str) -> Generator[Self, None, None]: in pattern, # recursive listing needed if explicit ** or any sub folder in pattern ) - def rglob(self, pattern: str) -> Generator[Self, None, None]: + def rglob( + self, pattern: str, case_sensitive: Optional[bool] = None + ) -> Generator[Self, None, None]: self._glob_checks(pattern) pattern_parts = PurePosixPath(pattern).parts - selector = _make_selector(("**",) + tuple(pattern_parts), _posix_flavour) + selector = _make_selector( + ("**",) + tuple(pattern_parts), _posix_flavour, case_sensitive=case_sensitive + ) yield from self._glob(selector, True) @@ -471,6 +494,41 @@ def iterdir(self) -> Generator[Self, None, None]: if f != self: # iterdir does not include itself in pathlib yield f + @staticmethod + def _walk_results_from_tree(root, tree, top_down=True): + """Utility to yield tuples in the form expected by `.walk` from the file + tree constructed by `_build_substree`. + """ + dirs = [] + files = [] + for item, branch in tree.items(): + files.append(item) if branch is None else dirs.append(item) + + if top_down: + yield root, dirs, files + + for dir in dirs: + yield from CloudPath._walk_results_from_tree(root / dir, tree[dir], top_down=top_down) + + if not top_down: + yield root, dirs, files + + def walk( + self, + top_down: bool = True, + on_error: Optional[Callable] = None, + follow_symlinks: bool = False, + ) -> Generator[Tuple[Self, List[str], List[str]], None, None]: + try: + file_tree = self._build_subtree(recursive=True) # walking is always recursive + yield from self._walk_results_from_tree(self, file_tree, top_down=top_down) + + except Exception as e: + if on_error is not None: + on_error(e) + else: + raise + def open( self, mode: str = "r", @@ -647,6 +705,9 @@ def read_text(self, encoding: Optional[str] = None, errors: Optional[str] = None with self.open(mode="r", encoding=encoding, errors=errors) as f: return f.read() + def is_junction(self): + return False # only windows paths can be junctions, not cloudpaths + # ====================== DISPATCHED TO POSIXPATH FOR PURE PATHS ====================== # Methods that are dispatched to exactly how pathlib.PurePosixPath would calculate it on # self._path for pure paths (does not matter if file exists); @@ -692,8 +753,8 @@ def __truediv__(self, other: Union[str, PurePosixPath]) -> Self: return self._dispatch_to_path("__truediv__", other) - def joinpath(self, *args: Union[str, os.PathLike]) -> Self: - return self._dispatch_to_path("joinpath", *args) + def joinpath(self, *pathsegments: Union[str, os.PathLike]) -> Self: + return self._dispatch_to_path("joinpath", *pathsegments) def absolute(self) -> Self: return self @@ -704,7 +765,7 @@ def is_absolute(self) -> bool: def resolve(self, strict: bool = False) -> Self: return self - def relative_to(self, other: Self) -> PurePosixPath: + def relative_to(self, other: Self, walk_up: bool = False) -> PurePosixPath: # We don't dispatch regularly since this never returns a cloud path (since it is relative, and cloud paths are # absolute) if not isinstance(other, CloudPath): @@ -713,7 +774,13 @@ def relative_to(self, other: Self) -> PurePosixPath: raise ValueError( f"{self} is a {self.cloud_prefix} path, but {other} is a {other.cloud_prefix} path" ) - return self._path.relative_to(other._path) + + kwargs = dict(walk_up=walk_up) + + if sys.version_info < (3, 12): + kwargs.pop("walk_up") + + return self._path.relative_to(other._path, **kwargs) # type: ignore[call-arg] def is_relative_to(self, other: Self) -> bool: try: @@ -726,12 +793,17 @@ def is_relative_to(self, other: Self) -> bool: def name(self) -> str: return self._dispatch_to_path("name") - def match(self, path_pattern: str) -> bool: + def match(self, path_pattern: str, case_sensitive: Optional[bool] = None) -> bool: # strip scheme from start of pattern before testing if path_pattern.startswith(self.anchor + self.drive + "/"): path_pattern = path_pattern[len(self.anchor + self.drive + "/") :] - return self._dispatch_to_path("match", path_pattern) + kwargs = dict(case_sensitive=case_sensitive) + + if sys.version_info < (3, 12): + kwargs.pop("case_sensitive") + + return self._dispatch_to_path("match", path_pattern, **kwargs) @property def parent(self) -> Self: @@ -771,6 +843,12 @@ def with_stem(self, stem: str) -> Self: def with_name(self, name: str) -> Self: return self._dispatch_to_path("with_name", name) + def with_segments(self, *pathsegments) -> Self: + """Create a new CloudPath with the same client out of the given segments. + The first segment will be interpreted as the bucket/container name. + """ + return self._new_cloudpath("/".join(pathsegments)) + def with_suffix(self, suffix: str) -> Self: return self._dispatch_to_path("with_suffix", suffix) @@ -1244,3 +1322,16 @@ def scandir( ) _scandir = scandir # Py 3.11 compatibility + + def walk(self): + # split into dirs and files + dirs_files = defaultdict(list) + with self.scandir(self) as items: + for child in items: + dirs_files[child.is_dir()].append(child) + + # top-down, so yield self before recursive call + yield self, [f.name for f in dirs_files[True]], [f.name for f in dirs_files[False]] + + for child_dir in dirs_files[True]: + yield from child_dir.walk() diff --git a/pyproject.toml b/pyproject.toml index 998de781..1aec1d6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] requires-python = ">=3.7" dependencies = [ @@ -49,7 +50,7 @@ all = ["cloudpathlib[azure]", "cloudpathlib[gs]", "cloudpathlib[s3]"] [tool.black] line-length = 99 -target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] include = '\.pyi?$|\.ipynb$' extend-exclude = ''' /( diff --git a/requirements-dev.txt b/requirements-dev.txt index 3acae4f4..ade42b38 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,7 +20,8 @@ pillow psutil pydantic pytest -pytest-cases +# pytest-cases +git+https://github.com/jayqi/python-pytest-cases@packaging-version pytest-cov pytest-xdist python-dotenv diff --git a/tests/performance/perf_file_listing.py b/tests/performance/perf_file_listing.py index c555b612..3551a85d 100644 --- a/tests/performance/perf_file_listing.py +++ b/tests/performance/perf_file_listing.py @@ -10,3 +10,12 @@ def glob(folder, recursive): return {"n_items": len(list(folder.rglob("*.item")))} else: return {"n_items": len(list(folder.glob("*.item")))} + + +def walk(folder): + n_items = 0 + + for _, _, files in folder.walk(): + n_items += len(files) + + return {"n_items": n_items} diff --git a/tests/performance/runner.py b/tests/performance/runner.py index 494882fd..859b5494 100644 --- a/tests/performance/runner.py +++ b/tests/performance/runner.py @@ -14,7 +14,7 @@ from cloudpathlib import CloudPath -from perf_file_listing import folder_list, glob +from perf_file_listing import folder_list, glob, walk # make loguru and tqdm play nicely together @@ -137,6 +137,15 @@ def main(root, iterations, burn_in): PerfRunConfig(name="Glob deep non-recursive", args=[deep, False], kwargs={}), ], ), + ( + "Walk scenarios", + walk, + [ + PerfRunConfig(name="Walk shallow", args=[shallow], kwargs={}), + PerfRunConfig(name="Walk normal", args=[normal], kwargs={}), + PerfRunConfig(name="Walk deep", args=[deep], kwargs={}), + ], + ), ] logger.info( diff --git a/tests/test_caching.py b/tests/test_caching.py index 607e3da4..15321129 100644 --- a/tests/test_caching.py +++ b/tests/test_caching.py @@ -231,6 +231,7 @@ def test_interaction_with_local_cache_dir(rig: CloudProviderTestRig, tmpdir): assert cp.client.file_cache_mode == FileCacheMode.tmp_dir # download from cloud into the cache + sleep(0.1) # test can be flaky saing that the cache dir doesn't exist yet with cp.open("r") as f: _ = f.read() diff --git a/tests/test_cloudpath_file_io.py b/tests/test_cloudpath_file_io.py index 8b9a7051..af8f9e63 100644 --- a/tests/test_cloudpath_file_io.py +++ b/tests/test_cloudpath_file_io.py @@ -1,8 +1,9 @@ from datetime import datetime import os -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath import pickle from shutil import rmtree +import sys from time import sleep import pytest @@ -104,11 +105,12 @@ def _make_glob_directory(root): rmtree(local_root) -def _assert_glob_results_match(cloud_results, local_results, cloud_root, local_root): - def _lstrip_path_root(path, root): - rel_path = str(path)[len(str(root)) :] - return rel_path.rstrip("/") # agnostic to trailing slash +def _lstrip_path_root(path, root): + rel_path = str(path)[len(str(root)) :] + return rel_path.rstrip("/") # agnostic to trailing slash + +def _assert_glob_results_match(cloud_results, local_results, cloud_root, local_root): local_results_no_root = [_lstrip_path_root(c.as_posix(), local_root) for c in local_results] cloud_results_no_root = [_lstrip_path_root(c, cloud_root) for c in cloud_results] @@ -119,6 +121,26 @@ def _lstrip_path_root(path, root): assert set(local_results_no_root) == set(cloud_results_no_root) +def _assert_walk_results_match(cloud_results, local_results, cloud_root, local_root): + # order not guaranteed, so strip use top as keys for matching + cloud_results = { + _lstrip_path_root(top, cloud_root): [dirs, files] for top, dirs, files in cloud_results + } + local_results = { + _lstrip_path_root(Path(top).as_posix(), local_root): [dirs, files] + for top, dirs, files in local_results + } + + assert set(cloud_results.keys()) == set(local_results.keys()) + + for top in local_results: + local_dirs, local_files = local_results[top] + cloud_dirs, cloud_files = cloud_results[top] + + assert set(cloud_dirs) == set(local_dirs) # order not guaranteed + assert set(local_files) == set(cloud_files) # order not guaranteed + + def test_iterdir(glob_test_dirs): cloud_root, local_root = glob_test_dirs @@ -138,6 +160,25 @@ def test_iterdir(glob_test_dirs): ) +def test_walk(glob_test_dirs): + cloud_root, local_root = glob_test_dirs + + # walk only natively available in python 3.12+ + local_results = local_root.walk() if hasattr(local_root, "walk") else os.walk(local_root) + + _assert_walk_results_match(cloud_root.walk(), local_results, cloud_root, local_root) + + local_results = ( + local_root.walk(top_down=False) + if hasattr(local_root, "walk") + else os.walk(local_root, topdown=False) + ) + + _assert_walk_results_match( + cloud_root.walk(top_down=False), local_results, cloud_root, local_root + ) + + def test_list_buckets(rig): # test we can list buckets buckets = list(rig.path_class(f"{rig.path_class.cloud_prefix}").iterdir()) @@ -155,10 +196,10 @@ def test_glob(glob_test_dirs): # cases adapted from CPython glob tests: # https://github.com/python/cpython/blob/7ffe7ba30fc051014977c6f393c51e57e71a6648/Lib/test/test_pathlib.py#L1634-L1720 - def _check_glob(pattern, glob_method): + def _check_glob(pattern, glob_method, **kwargs): _assert_glob_results_match( - getattr(cloud_root, glob_method)(pattern), - getattr(local_root, glob_method)(pattern), + getattr(cloud_root, glob_method)(pattern, **kwargs), + getattr(local_root, glob_method)(pattern, **kwargs), cloud_root, local_root, ) @@ -190,6 +231,21 @@ def _check_glob(pattern, glob_method): dir_c_cloud.rglob("*/*"), dir_c_local.rglob("*/*"), dir_c_cloud, dir_c_local ) + # 3.12+ kwargs + if sys.version_info >= (3, 12): + _check_glob("dir*/FILE*", "glob", case_sensitive=False) + _check_glob("dir*/file*", "glob", case_sensitive=True) + _check_glob("dir*/FILE*", "rglob", case_sensitive=False) + _check_glob("dir*/file*", "rglob", case_sensitive=True) + + # test case insensitive for cloud; sensitive different pattern for local + _assert_glob_results_match( + dir_c_cloud.glob("FILE*", case_sensitive=False), + dir_c_local.glob("file*"), + dir_c_cloud, + dir_c_local, + ) + def test_glob_buckets(rig): # CloudPath("s3://").glob("*") results in error diff --git a/tests/test_cloudpath_instantiation.py b/tests/test_cloudpath_instantiation.py index 1ae100b2..366607fc 100644 --- a/tests/test_cloudpath_instantiation.py +++ b/tests/test_cloudpath_instantiation.py @@ -1,4 +1,7 @@ +import inspect import os +from pathlib import PurePath +import re import pytest @@ -83,3 +86,59 @@ def test_dependencies_not_loaded(rig, monkeypatch): def test_is_pathlike(rig): p = rig.create_cloud_path("dir_0") assert isinstance(p, os.PathLike) + + +def test_public_interface_is_superset(rig): + """Test that a CloudPath has all of the Path methods and properties. For methods + we also ensure that the only difference in the signature is that a CloudPath has + optional additional kwargs (which are likely added in subsequent Python versions). + """ + lp = PurePath(".") + cp = rig.create_cloud_path("dir_0/file0_0.txt") + + # Use regex to find the methods not implemented that are listed in the CloudPath code + not_implemented_section = re.search( + r"# =+ NOT IMPLEMENTED =+\n(.+?)\n\n", inspect.getsource(CloudPath), re.DOTALL + ) + + if not_implemented_section: + methods_not_implemented_str = not_implemented_section.group(1) + methods_not_implemented = re.findall(r"# (\w+)", methods_not_implemented_str) + + for name, lp_member in inspect.getmembers(lp): + if name.startswith("_") or name in methods_not_implemented: + continue + + # checks all public methods and properties + cp_member = getattr(cp, name, None) + assert cp_member is not None, f"CloudPath missing {name}" + + # for methods, checks the function signature + if callable(lp_member): + cp_signature = inspect.signature(cp_member) + lp_signature = inspect.signature(lp_member) + + # all parameters for Path method should be part of CloudPath signature + for parameter in lp_signature.parameters: + # some parameters like _deprecated in Path.is_relative_to are not really part of the signature + if parameter.startswith("_") or ( + name == "joinpath" and parameter in ["args", "pathsegments"] + ): # handle arg name change in 3.12 + continue + + assert ( + parameter in cp_signature.parameters + ), f"CloudPath.{name} missing parameter {parameter}" + + # extra parameters for CloudPath method should be optional with defaults + for parameter, param_details in cp_signature.parameters.items(): + if name == "joinpath" and parameter in [ + "args", + "pathsegments", + ]: # handle arg name change in 3.12 + continue + + if parameter not in lp_signature.parameters: + assert ( + param_details.default is not inspect.Parameter.empty + ), f"CloudPath.{name} added parameter {parameter} without a default" diff --git a/tests/test_cloudpath_manipulation.py b/tests/test_cloudpath_manipulation.py index 1b405957..a6aad166 100644 --- a/tests/test_cloudpath_manipulation.py +++ b/tests/test_cloudpath_manipulation.py @@ -1,4 +1,5 @@ from pathlib import PurePosixPath +import sys import pytest @@ -50,6 +51,12 @@ def test_relative_to(rig, azure_rig, gs_rig): assert rig.create_cloud_path("bucket/b/c/d.file").relative_to( rig.create_cloud_path("bucket/z") ) + + if sys.version_info >= (3, 12): + assert rig.create_cloud_path("bucket/path/to/file.txt").relative_to( + rig.create_cloud_path("other_bucket/path2"), walk_up=True + ) == PurePosixPath("../../bucket/path/to/file.txt") + with pytest.raises(ValueError): assert rig.create_cloud_path("a/b/c/d.file").relative_to(PurePosixPath("/a/b/c")) other_rig = azure_rig if rig.cloud_prefix != azure_rig.cloud_prefix else gs_rig @@ -73,6 +80,9 @@ def test_joins(rig): assert not rig.create_cloud_path("a/b/c/d").match("**/c") assert rig.create_cloud_path("a/b/c/d").match("a/*/c/d") + if sys.version_info >= (3, 12): + assert rig.create_cloud_path("a/b/c/d").match("A/*/C/D", case_sensitive=False) + assert rig.create_cloud_path("a/b/c/d").anchor == rig.cloud_prefix assert rig.create_cloud_path("a/b/c/d").parent == rig.create_cloud_path("a/b/c") @@ -107,6 +117,16 @@ def test_joins(rig): ) +def test_with_segments(rig): + assert rig.create_cloud_path("a/b/c/d").with_segments( + "x", "y", "z" + ) == rig.client_class().CloudPath(f"{rig.cloud_prefix}x/y/z") + + +def test_is_junction(rig): + assert not rig.create_cloud_path("a/b/foo").is_junction() + + def test_equality(rig): assert rig.create_cloud_path("a/b/foo") == rig.create_cloud_path("a/b/foo") assert hash(rig.create_cloud_path("a/b/foo")) == hash(rig.create_cloud_path("a/b/foo"))