From 7fdf54483753802da8da3b8fdf876823c112cdb8 Mon Sep 17 00:00:00 2001 From: ckunki Date: Fri, 30 Jan 2026 14:10:59 +0100 Subject: [PATCH 1/5] #271: Added method `relative_to` to interface `PathLike` --- doc/changes/unreleased.md | 6 ++++- exasol/bucketfs/_path.py | 23 ++++++++++++++++++ test/unit/test_bucket_path.py | 45 +++++++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) 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..10edf337 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,11 @@ def parent(self) -> PathLike: The logical parent of this path. """ + def relative_to(self, other: PathLike) -> PurePath: + """ + This path relative to the other. + """ + def as_uri(self) -> str: """ Represent the path as a file URI. Can be used to reconstruct the location/path. @@ -331,6 +337,23 @@ def root(self) -> str: def parent(self) -> PathLike: return BucketPath(self._path.parent, self._bucket_api) + def relative_to(self, other_pathlike: PathLike) -> PurePath: + if not isinstance(other_pathlike, BucketPath): + raise BucketFsError( + 'BucketPath.relative_to() called with other' + f' being an instance of {type(other_pathlike)}.' + ) + other = cast(BucketPath, other_pathlike) + if self._bucket_api != other.bucket_api: + raise BucketFsError( + 'BucketPath.relative_to() called with other' + f' from a foreign bucket {other._bucket_api}.' + ) + try: + return self._path.relative_to(other._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..bbe1e8ff 100644 --- a/test/unit/test_bucket_path.py +++ b/test/unit/test_bucket_path.py @@ -1,6 +1,8 @@ from itertools import chain -from pathlib import Path - +from pathlib import ( + Path, + PurePath, +) import pytest import exasol.bucketfs as bfs @@ -234,3 +236,42 @@ 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 = bfs.path.BucketPath("root", bucket_api=NotBucketPath()) + testee = bfs.path.BucketPath("testee", bucket_api=bucket_fake) + with pytest.raises(bfs.path.BucketFsError): + 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", [ + ("b", "b"), + ("b/c", "b/c"), +]) +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) From f1df8794607d613923b7ba5e2541ffe099ffd83c Mon Sep 17 00:00:00 2001 From: ckunki Date: Fri, 30 Jan 2026 14:14:13 +0100 Subject: [PATCH 2/5] Renamed parametrized test --- test/unit/test_bucket_path.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/test_bucket_path.py b/test/unit/test_bucket_path.py index bbe1e8ff..f8a3c6c4 100644 --- a/test/unit/test_bucket_path.py +++ b/test/unit/test_bucket_path.py @@ -268,8 +268,8 @@ def test_foreign_bucket(tmp_path, bucket_fake): @pytest.mark.parametrize("path, expected_relative", [ - ("b", "b"), - ("b/c", "b/c"), + ("a", "a"), + ("a/b", "a/b"), ]) def test_relative_to(path, expected_relative, bucket_fake): root = bfs.path.BucketPath("root", bucket_api=bucket_fake) From 72576ebfdc8890225d6a895cf04b08bb0937df17 Mon Sep 17 00:00:00 2001 From: ckunki Date: Fri, 30 Jan 2026 14:15:49 +0100 Subject: [PATCH 3/5] nox -s format:fix --- exasol/bucketfs/_path.py | 8 ++++---- test/unit/test_bucket_path.py | 15 +++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/exasol/bucketfs/_path.py b/exasol/bucketfs/_path.py index 10edf337..4d4b8e85 100644 --- a/exasol/bucketfs/_path.py +++ b/exasol/bucketfs/_path.py @@ -340,14 +340,14 @@ def parent(self) -> PathLike: def relative_to(self, other_pathlike: PathLike) -> PurePath: if not isinstance(other_pathlike, BucketPath): raise BucketFsError( - 'BucketPath.relative_to() called with other' - f' being an instance of {type(other_pathlike)}.' + "BucketPath.relative_to() called with other" + f" being an instance of {type(other_pathlike)}." ) other = cast(BucketPath, other_pathlike) if self._bucket_api != other.bucket_api: raise BucketFsError( - 'BucketPath.relative_to() called with other' - f' from a foreign bucket {other._bucket_api}.' + "BucketPath.relative_to() called with other" + f" from a foreign bucket {other._bucket_api}." ) try: return self._path.relative_to(other._path) diff --git a/test/unit/test_bucket_path.py b/test/unit/test_bucket_path.py index f8a3c6c4..b0b9e9b1 100644 --- a/test/unit/test_bucket_path.py +++ b/test/unit/test_bucket_path.py @@ -3,6 +3,7 @@ Path, PurePath, ) + import pytest import exasol.bucketfs as bfs @@ -253,8 +254,7 @@ 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'" + bfs.path.BucketFsError, match="'b' is not in the subpath of 'a'" ): b.relative_to(a) @@ -267,10 +267,13 @@ def test_foreign_bucket(tmp_path, bucket_fake): testee.relative_to(foreign) -@pytest.mark.parametrize("path, expected_relative", [ - ("a", "a"), - ("a/b", "a/b"), -]) +@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 From 7285ae8c790db1baf68a50b38beb64b9b59f964d Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 2 Feb 2026 13:32:02 +0100 Subject: [PATCH 4/5] Fixed test logic as pointed out by peer-reviewer --- test/unit/test_bucket_path.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/unit/test_bucket_path.py b/test/unit/test_bucket_path.py index b0b9e9b1..17427e03 100644 --- a/test/unit/test_bucket_path.py +++ b/test/unit/test_bucket_path.py @@ -244,9 +244,12 @@ class NotBucketPath(bfs.path.PathLike): def test_relative_not_bucket_path(bucket_fake): - root = bfs.path.BucketPath("root", bucket_api=NotBucketPath()) + root = NotBucketPath() testee = bfs.path.BucketPath("testee", bucket_api=bucket_fake) - with pytest.raises(bfs.path.BucketFsError): + with pytest.raises( + bfs.path.BucketFsError, + match="called with other being an instance of .*NotBucketPath", + ): testee.relative_to(root) From 5d236c2a1bab73be9b1b537b4b442157989ad8b7 Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 2 Feb 2026 14:52:28 +0100 Subject: [PATCH 5/5] Updated docstring for new method relative_to() --- exasol/bucketfs/_path.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/exasol/bucketfs/_path.py b/exasol/bucketfs/_path.py index 4d4b8e85..9f27e6ec 100644 --- a/exasol/bucketfs/_path.py +++ b/exasol/bucketfs/_path.py @@ -75,7 +75,16 @@ def parent(self) -> PathLike: def relative_to(self, other: PathLike) -> PurePath: """ - This path relative to the other. + 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: @@ -337,20 +346,20 @@ def root(self) -> str: def parent(self) -> PathLike: return BucketPath(self._path.parent, self._bucket_api) - def relative_to(self, other_pathlike: PathLike) -> PurePath: - if not isinstance(other_pathlike, BucketPath): + 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_pathlike)}." + f" being an instance of {type(other)}." ) - other = cast(BucketPath, other_pathlike) - if self._bucket_api != other.bucket_api: + 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_api}." + f" from a foreign bucket {other_bucket_path._bucket_api}." ) try: - return self._path.relative_to(other._path) + return self._path.relative_to(other_bucket_path._path) except ValueError as ex: raise BucketFsError(repr(ex)) from ex