diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index a14290ed..79936c94 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -1,9 +1,13 @@ # Unreleased +## Features + +* #271: Added method `relative_to` to interface `PathLike` + ## Bug Fixing * #262: Fixed a wrong type interpretation in `path.write`. The chunks are now passed as a Sequence, not Iterable. ## Internal -* #260: Re-locked transitive dependencies urllib3, filelock, and Werkzeug and update to exasol-toolbox 4.0.0 \ No newline at end of file +* #260: Re-locked transitive dependencies urllib3, filelock, and Werkzeug and update to exasol-toolbox 4.0.0 diff --git a/exasol/bucketfs/_path.py b/exasol/bucketfs/_path.py index 3fca590f..9f27e6ec 100644 --- a/exasol/bucketfs/_path.py +++ b/exasol/bucketfs/_path.py @@ -19,6 +19,7 @@ from typing import ( BinaryIO, Protocol, + cast, ) from exasol.saas.client.api_access import get_database_id @@ -72,6 +73,20 @@ def parent(self) -> PathLike: The logical parent of this path. """ + def relative_to(self, other: PathLike) -> PurePath: + """ + Return an instance of PurePath interpreting this part relative to + the specified ``other``. + + Note: ``other`` should be an instance of the same class implementing + the PathLike interface as the current one, e.g. BucketPath. + + Example: + a = build_path(..., path="parent") + b = build_path(..., path="parent/child") + assert b.relative(a) == PurePath("child") + """ + def as_uri(self) -> str: """ Represent the path as a file URI. Can be used to reconstruct the location/path. @@ -331,6 +346,23 @@ def root(self) -> str: def parent(self) -> PathLike: return BucketPath(self._path.parent, self._bucket_api) + def relative_to(self, other: PathLike) -> PurePath: + if not isinstance(other, BucketPath): + raise BucketFsError( + "BucketPath.relative_to() called with other" + f" being an instance of {type(other)}." + ) + other_bucket_path = cast(BucketPath, other) + if self._bucket_api != other_bucket_path.bucket_api: + raise BucketFsError( + "BucketPath.relative_to() called with other" + f" from a foreign bucket {other_bucket_path._bucket_api}." + ) + try: + return self._path.relative_to(other_bucket_path._path) + except ValueError as ex: + raise BucketFsError(repr(ex)) from ex + def as_uri(self) -> str: return self._path.as_uri() diff --git a/test/unit/test_bucket_path.py b/test/unit/test_bucket_path.py index 916ee79c..17427e03 100644 --- a/test/unit/test_bucket_path.py +++ b/test/unit/test_bucket_path.py @@ -1,5 +1,8 @@ from itertools import chain -from pathlib import Path +from pathlib import ( + Path, + PurePath, +) import pytest @@ -234,3 +237,47 @@ def test_eq(bucket_fake, file1, file2, expectation): a = path / file1 b = path / file2 assert (a == b) == expectation + + +class NotBucketPath(bfs.path.PathLike): + pass + + +def test_relative_not_bucket_path(bucket_fake): + root = NotBucketPath() + testee = bfs.path.BucketPath("testee", bucket_api=bucket_fake) + with pytest.raises( + bfs.path.BucketFsError, + match="called with other being an instance of .*NotBucketPath", + ): + testee.relative_to(root) + + +def test_relative_outside(bucket_fake): + a = bfs.path.BucketPath("a", bucket_api=bucket_fake) + b = bfs.path.BucketPath("b", bucket_api=bucket_fake) + with pytest.raises( + bfs.path.BucketFsError, match="'b' is not in the subpath of 'a'" + ): + b.relative_to(a) + + +def test_foreign_bucket(tmp_path, bucket_fake): + api2 = bfs._buckets.MountedBucket(base_path=tmp_path) + foreign = bfs.path.BucketPath("root", bucket_api=api2) + testee = bfs.path.BucketPath("root", bucket_api=bucket_fake) + with pytest.raises(bfs.path.BucketFsError, match="other from a foreign bucket"): + testee.relative_to(foreign) + + +@pytest.mark.parametrize( + "path, expected_relative", + [ + ("a", "a"), + ("a/b", "a/b"), + ], +) +def test_relative_to(path, expected_relative, bucket_fake): + root = bfs.path.BucketPath("root", bucket_api=bucket_fake) + testee = root / path + assert testee.relative_to(root) == PurePath(expected_relative)