diff --git a/src/labapi/util/path.py b/src/labapi/util/path.py index d889cdb7..d6634bad 100644 --- a/src/labapi/util/path.py +++ b/src/labapi/util/path.py @@ -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: diff --git a/tests/util/test_path.py b/tests/util/test_path.py index 38058126..7762390e 100644 --- a/tests/util/test_path.py +++ b/tests/util/test_path.py @@ -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(