Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 20 additions & 17 deletions src/labapi/util/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,37 +383,40 @@ def __getitem__(
"""Return node name(s)."""
return self._parts[idx]

@override
def __hash__(self) -> int:
"""Hash the resolved path so equal paths hash equally.

Mirrors ``__eq__``, which compares resolved paths. An unresolvable
relative path (no parent anchor) falls back to its unresolved state;
such a path is only ever equal to itself.
def _comparison_key(self) -> tuple[bool, tuple[UnescapedSegment, ...]]:
"""Return the value that equality and hashing are based on.

Uses the resolved path when it can be resolved; an unresolvable
relative path (no parent anchor) falls back to its own normalized
``(absoluteness, segments)`` state. ``__eq__`` and ``__hash__`` share
this key so the hash/eq contract holds for every path -- including two
equal unanchored relative paths, which resolve() cannot resolve.
"""
try:
resolved = self.resolve()
except PathError:
return hash((self._absolute, tuple(self._parts)))
return hash((resolved._absolute, tuple(resolved._parts)))
return (self._absolute, tuple(self._parts))
return (resolved._absolute, tuple(resolved._parts))

@override
def __hash__(self) -> int:
"""Hash equal paths equally; see :meth:`_comparison_key`."""
return hash(self._comparison_key())

@override
def __eq__(self, other: object) -> bool:
"""Return ``True`` if ``other`` has the same path semantics.

Equality compares absoluteness, normalized segments, and any stored
parent anchor.
Compares the resolved path when possible; two equal unanchored
relative paths (which cannot be resolved) still compare equal via the
shared :meth:`_comparison_key`, keeping equality consistent with
``__hash__``.
"""
if self is other:
return True
if not isinstance(other, NotebookPath):
return False
try:
a = self.resolve()
b = other.resolve()
return a._absolute == b._absolute and a._parts == b._parts
except PathError:
return False
return self._comparison_key() == other._comparison_key()

@override
def __repr__(self) -> str:
Expand Down
17 changes: 17 additions & 0 deletions tests/util/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,23 @@ def test_notebook_path_anchored_equals_absolute_with_matching_hash():
assert {anchored: "value"}.get(absolute) == "value"


def test_notebook_path_equal_relative_paths_are_equal_and_hashable():
"""Two equal unanchored relative paths must compare equal and hash equal."""
a = NotebookPath(EscapedSegment("foo/bar"))
b = NotebookPath(EscapedSegment("foo/bar"))

assert a == b
assert hash(a) == hash(b)
assert b in {a}
assert len({a, b}) == 1
assert {a: "value"}[b] == "value"

# Distinct relative paths stay unequal, and an unanchored relative path is
# not equal to the same-segment absolute path.
assert a != NotebookPath(EscapedSegment("foo/baz"))
assert a != NotebookPath(EscapedSegment("/foo/bar"))


def test_notebook_path_relative_to_identical_relative_returns_empty():
"""relative_to an identical relative path returns an empty relative path."""
result = NotebookPath(EscapedSegment("a")).relative_to(
Expand Down