Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-pathable for openSUSE:Factory 
checked in at 2026-05-26 16:34:14
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-pathable (Old)
 and      /work/SRC/openSUSE:Factory/.python-pathable.new.2084 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-pathable"

Tue May 26 16:34:14 2026 rev:7 rq:1355104 version:0.6.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-pathable/python-pathable.changes  
2026-03-16 14:21:15.175063116 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-pathable.new.2084/python-pathable.changes    
    2026-05-26 16:34:24.304923134 +0200
@@ -1,0 +2,7 @@
+Mon May 25 20:06:50 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 0.6.0:
+  * Per-accessor identity contract #121
+  * Python 3.14 support #106
+
+-------------------------------------------------------------------

Old:
----
  pathable-0.5.0.tar.gz

New:
----
  pathable-0.6.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-pathable.spec ++++++
--- /var/tmp/diff_new_pack.FTPXYj/_old  2026-05-26 16:34:25.060954413 +0200
+++ /var/tmp/diff_new_pack.FTPXYj/_new  2026-05-26 16:34:25.060954413 +0200
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-pathable
-Version:        0.5.0
+Version:        0.6.0
 Release:        0
 Summary:        Object-oriented paths
 License:        Apache-2.0

++++++ pathable-0.5.0.tar.gz -> pathable-0.6.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pathable-0.5.0/.github/workflows/python-tests.yml 
new/pathable-0.6.0/.github/workflows/python-tests.yml
--- old/pathable-0.5.0/.github/workflows/python-tests.yml       2026-02-20 
09:46:04.000000000 +0100
+++ new/pathable-0.6.0/.github/workflows/python-tests.yml       2026-05-19 
20:14:19.000000000 +0200
@@ -14,7 +14,7 @@
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ['3.10', '3.11', '3.12', '3.13']
+        python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
       fail-fast: false
     steps:
       - uses: actions/checkout@v6
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pathable-0.5.0/README.md new/pathable-0.6.0/README.md
--- old/pathable-0.5.0/README.md        2026-02-20 09:46:04.000000000 +0100
+++ new/pathable-0.6.0/README.md        2026-05-19 20:14:19.000000000 +0200
@@ -118,9 +118,30 @@
 
 Equality and ordering:
 
-* `BasePath` equality, hashing, and ordering are all based on both `separator` 
and `parts`.
-* Ordering is separator-sensitive and deterministic, even when parts mix types 
(e.g. ints and strings).
+* Two `BasePath` instances are equal if their `parts` are equal. The separator 
is presentation only — `BasePath("a", separator="/") == BasePath("a", 
separator=".")`.
+* Two `AccessorPath` instances are equal if they have equal `parts` *and* 
their accessors compare equal under the accessor's own `__eq__`. A plain 
`BasePath` is never equal to an `AccessorPath`.
 * Path parts are type-sensitive (`0` is not equal to `"0"`).
+* Ordering is address-based: separator is not part of the order, and it 
remains deterministic across mixed part types. For `AccessorPath`, different 
bindings with the same `parts` may compare ordering-equivalent while remaining 
unequal.
+
+Identity and lifecycle:
+
+* Build one accessor per resource and reuse it for every path you derive. 
`LookupPath.from_lookup(data)` constructs a fresh accessor on each call, which 
is convenient for one-off use but defeats the cache when called repeatedly over 
the same data:
+
+```python
+from pathable import LookupPath
+from pathable.accessors import LookupAccessor
+
+# Construct the accessor once, reuse it.
+accessor = LookupAccessor(data)
+root = LookupPath(accessor)
+
+# Every path derived from `accessor` shares its cache.
+a = root / "parts" / "part1"
+b = root / "parts" / "part1"
+assert a == b
+```
+
+* `path.is_same_binding(other)` is a stricter version of `==` that 
additionally requires both paths to share the *same accessor instance* (object 
identity), not just an `==`-equal one. Use it when you need to verify cache 
attribution or detect accidental accessor swaps.
 
 Lookup caching:
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pathable-0.5.0/pathable/__init__.py 
new/pathable-0.6.0/pathable/__init__.py
--- old/pathable-0.5.0/pathable/__init__.py     2026-02-20 09:46:04.000000000 
+0100
+++ new/pathable-0.6.0/pathable/__init__.py     2026-05-19 20:14:19.000000000 
+0200
@@ -11,7 +11,7 @@
 
 __author__ = "Artur Maciag"
 __email__ = "[email protected]"
-__version__ = "0.5.0"
+__version__ = "0.6.0"
 __url__ = "https://github.com/p1c2u/pathable";
 __license__ = "Apache License, Version 2.0"
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pathable-0.5.0/pathable/accessors.py 
new/pathable-0.6.0/pathable/accessors.py
--- old/pathable-0.5.0/pathable/accessors.py    2026-02-20 09:46:04.000000000 
+0100
+++ new/pathable-0.6.0/pathable/accessors.py    2026-05-19 20:14:19.000000000 
+0200
@@ -40,7 +40,16 @@
     def __eq__(self, other: object) -> Any:
         if not isinstance(other, NodeAccessor):
             return NotImplemented
-        return self.node == other.node
+        # Object identity is the only universally-correct default. The
+        # base accessor cannot know what makes a wrapped resource "the
+        # same" — that's a per-resource-type question. Subclasses that
+        # represent a resource with a canonical name (URL, filesystem
+        # path, storage options, in-memory object reference) override
+        # both __eq__ and __hash__ in lockstep.
+        return self is other
+
+    def __hash__(self) -> int:
+        return object.__hash__(self)
 
     def stat(self, parts: Sequence[K]) -> dict[str, Any] | None:
         raise NotImplementedError
@@ -169,6 +178,18 @@
 
 class PathAccessor(NodeAccessor[Path, str, bytes]):
 
+    def __eq__(self, other: object) -> Any:
+        if not isinstance(other, PathAccessor):
+            return NotImplemented
+        # pathlib.Path is hashable and value-equal on its canonical
+        # string form, so PathAccessor can use value-equality on the
+        # wrapped Path. Same-class check keeps behavioral subclasses
+        # in their own equivalence class.
+        return type(self) is type(other) and self._node == other._node
+
+    def __hash__(self) -> int:
+        return hash((type(self), self._node))
+
     def stat(self, parts: Sequence[str]) -> dict[str, Any] | None:
         subpath = self.node.joinpath(*parts)
         try:
@@ -323,6 +344,20 @@
 
 class LookupAccessor(CachedSubscriptableAccessor[LookupKey, LookupValue]):
 
+    def __eq__(self, other: object) -> Any:
+        if not isinstance(other, LookupAccessor):
+            return NotImplemented
+        # The wrapped node is typically an anonymous, mutable, unhashable
+        # container (dict, list). Its only canonical identity is its
+        # object reference: two LookupAccessors over the same Python
+        # object refer to the same logical resource; two over distinct
+        # value-equal objects do not. id() is safe in __hash__ because
+        # the accessor holds a strong reference to _node for its lifetime.
+        return type(self) is type(other) and self._node is other._node
+
+    def __hash__(self) -> int:
+        return hash((type(self), id(self._node)))
+
     @classmethod
     def _is_traversable_node(cls, node: LookupNode) -> bool:
         return isinstance(node, Mapping | list)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pathable-0.5.0/pathable/paths.py 
new/pathable-0.6.0/pathable/paths.py
--- old/pathable-0.5.0/pathable/paths.py        2026-02-20 09:46:04.000000000 
+0100
+++ new/pathable-0.6.0/pathable/paths.py        2026-05-19 20:14:19.000000000 
+0200
@@ -32,9 +32,18 @@
 TDefault = TypeVar("TDefault")
 
 
-@dataclass(frozen=True, init=False)
+@dataclass(frozen=True, init=False, eq=False)
 class BasePath:
-    """Base path."""
+    """Base path.
+
+    Identity is the *address*: two paths are equal if their ``parts`` are
+    equal. The separator is presentation only — two paths that name the
+    same address but render differently are still equal. Subclasses that
+    introduce a resource binding (``AccessorPath``) extend the identity
+    to include the binding and override ``__eq__`` accordingly; the
+    BasePath/AccessorPath boundary is the only place class participates
+    in equality.
+    """
 
     parts: tuple[Hashable, ...]
     separator: str = SEPARATOR
@@ -279,8 +288,18 @@
     def __repr__(self) -> str:
         return f"{self.__class__.__name__}({str(self)!r})"
 
+    def _identity_key(self) -> tuple[Any, ...]:
+        # Address-only identity for BasePath. Separator is presentation,
+        # not identity. AccessorPath overrides this to include the
+        # accessor as binding.
+        return (self.parts,)
+
+    @cached_property
+    def _hash(self) -> int:
+        return hash(self._identity_key())
+
     def __hash__(self) -> int:
-        return hash((self.separator, self.parts))
+        return self._hash
 
     def __truediv__(self: TBasePath, key: Any) -> TBasePath:
         try:
@@ -300,42 +319,40 @@
         except TypeError:
             return NotImplemented
 
-    def __eq__(self, other: Any) -> bool:
+    def __eq__(self, other: object) -> bool:
         if not isinstance(other, BasePath):
             return NotImplemented
-        return (self.separator, self.parts) == (other.separator, other.parts)
+        # AccessorPath overrides __eq__ to enforce cross-class
+        # discrimination (an AccessorPath carries a binding that a plain
+        # BasePath does not, so they are never equal). Here we are on the
+        # BasePath-side dispatch; if `other` is an AccessorPath, Python's
+        # reflected-dispatch rules have already given AccessorPath.__eq__
+        # the first chance to answer. Reaching this branch means both
+        # sides are plain BasePaths (or AccessorPath's __eq__ returned
+        # NotImplemented), so address-only comparison is correct.
+        return self.parts == other.parts
 
     def __lt__(self, other: Any) -> bool:
         if not isinstance(other, BasePath):
             return NotImplemented
-        return (self.separator, self._cmp_parts) < (
-            other.separator,
-            other._cmp_parts,
-        )
+        # Ordering is address-based: separator is presentation, and
+        # AccessorPath bindings are intentionally outside the sort key.
+        return self._cmp_parts < other._cmp_parts
 
     def __le__(self, other: Any) -> bool:
         if not isinstance(other, BasePath):
             return NotImplemented
-        return (self.separator, self._cmp_parts) <= (
-            other.separator,
-            other._cmp_parts,
-        )
+        return self._cmp_parts <= other._cmp_parts
 
     def __gt__(self, other: Any) -> bool:
         if not isinstance(other, BasePath):
             return NotImplemented
-        return (self.separator, self._cmp_parts) > (
-            other.separator,
-            other._cmp_parts,
-        )
+        return self._cmp_parts > other._cmp_parts
 
     def __ge__(self, other: Any) -> bool:
         if not isinstance(other, BasePath):
             return NotImplemented
-        return (self.separator, self._cmp_parts) >= (
-            other.separator,
-            other._cmp_parts,
-        )
+        return self._cmp_parts >= other._cmp_parts
 
 
 class AccessorPath(BasePath, Generic[N, K, V]):
@@ -391,6 +408,43 @@
             accessor=self.accessor,
         )
 
+    def _identity_key(self) -> tuple[Any, ...]:
+        # Identity = (address, binding). The accessor's own __eq__ and
+        # __hash__ decide what makes two accessors the same resource;
+        # the path layer simply delegates to it via tuple comparison.
+        return (self.parts, self.accessor)
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, BasePath):
+            return NotImplemented
+        # Cross-class discrimination: a plain BasePath has no binding,
+        # so it can never equal an AccessorPath. This preserves
+        # transitivity — otherwise BasePath("x") could simultaneously
+        # equal two AccessorPaths over distinct resources.
+        if not isinstance(other, AccessorPath):
+            return False
+        return self.parts == other.parts and self.accessor == other.accessor
+
+    # Re-bind __hash__: defining __eq__ on a class otherwise sets
+    # __hash__ to None. The BasePath implementation dispatches through
+    # _identity_key, which we override above, so this is the correct
+    # hash for AccessorPath identity (parts, accessor).
+    __hash__ = BasePath.__hash__
+
+    def is_same_binding(self, other: object) -> bool:
+        """Return True if ``other`` is an equal address bound to the
+        same accessor *instance* (object identity on the accessor).
+
+        Stricter than ``==``, which only requires that the accessors
+        compare equal under their own ``__eq__`` semantics. Use this
+        when you need to assert that two paths are not just naming the
+        same resource but are literally backed by the same accessor
+        object — for example, to verify cache attribution.
+        """
+        if not isinstance(other, AccessorPath):
+            return False
+        return self.parts == other.parts and self.accessor is other.accessor
+
     def __rtruediv__(self: TAccessorPath, key: Hashable) -> TAccessorPath:
         try:
             return self._from_parts(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pathable-0.5.0/pyproject.toml 
new/pathable-0.6.0/pyproject.toml
--- old/pathable-0.5.0/pyproject.toml   2026-02-20 09:46:04.000000000 +0100
+++ new/pathable-0.6.0/pyproject.toml   2026-05-19 20:14:19.000000000 +0200
@@ -35,7 +35,7 @@
 
 [tool.poetry]
 name = "pathable"
-version = "0.5.0"
+version = "0.6.0"
 description = "Object-oriented paths"
 authors = ["Artur Maciag <[email protected]>"]
 license = "Apache-2.0"
@@ -52,6 +52,7 @@
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
     "Programming Language :: Python :: 3.13",
+    "Programming Language :: Python :: 3.14",
     "Topic :: Software Development :: Libraries"
 ]
 
@@ -94,7 +95,7 @@
 tag_template = "{new_version}"
 
 [tool.tbump.version]
-current = "0.5.0"
+current = "0.6.0"
 regex = '''
   (?P<major>\d+)
   \.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pathable-0.5.0/tests/unit/test_paths.py 
new/pathable-0.6.0/tests/unit/test_paths.py
--- old/pathable-0.5.0/tests/unit/test_paths.py 2026-02-20 09:46:04.000000000 
+0100
+++ new/pathable-0.6.0/tests/unit/test_paths.py 2026-05-19 20:14:19.000000000 
+0200
@@ -10,6 +10,7 @@
 
 from pathable.accessors import LookupAccessor
 from pathable.accessors import NodeAccessor
+from pathable.accessors import PathAccessor
 from pathable.parsers import SEPARATOR
 from pathable.paths import AccessorPath
 from pathable.paths import BasePath
@@ -352,25 +353,26 @@
     def test_empty(self):
         p = BasePath()
 
-        assert hash(p) == hash((p.separator, p.parts))
+        assert hash(p) == hash((p.parts,))
 
     def test_single(self):
         p = BasePath("part1")
 
-        assert hash(p) == hash((p.separator, p.parts))
+        assert hash(p) == hash((p.parts,))
 
     def test_double(self):
         args = ["part1", "part2"]
         p = BasePath(*args)
 
-        assert hash(p) == hash((p.separator, p.parts))
+        assert hash(p) == hash((p.parts,))
 
     def test_separator(self):
         args = ["part1", "part2"]
         separator = ","
         p = BasePath(*args, separator=separator)
 
-        assert hash(p) == hash((p.separator, p.parts))
+        # Separator is presentation, not identity; hash is parts-only.
+        assert hash(p) == hash((p.parts,))
 
     def test_cparts_cached(self):
         part = MockPart("part1")
@@ -382,18 +384,31 @@
 
         # Hashing does not stringify parts.
         assert part.str_counter == 0
-        assert hash(p) == hash((p.separator, p.parts))
+        assert hash(p) == hash((p.parts,))
         assert part.str_counter == 0
-        assert hash(p) == hash((p.separator, p.parts))
+        assert hash(p) == hash((p.parts,))
         assert part.str_counter == 0
 
-    def test_separator_part_of_hash(self):
+    def test_separator_not_part_of_identity(self):
+        # Separator is presentation only: two paths with the same parts
+        # but different separators name the same address and are equal
+        # under both __eq__ and __hash__.
         p1 = BasePath("a", separator="/")
         p2 = BasePath("a", separator=".")
-        assert p1 != p2
-        assert len({p1, p2}) == 2
-        assert {p1: 1, p2: 2}[p1] == 1
-        assert {p1: 1, p2: 2}[p2] == 2
+        assert p1 == p2
+        assert hash(p1) == hash(p2)
+        assert len({p1, p2}) == 1
+
+    def test_hash_is_cached(self):
+        # Inspect __dict__ directly: cached_property stores the computed
+        # value under its attribute name on the instance dict, so its
+        # presence is the load-bearing signal that caching happened.
+        p = BasePath("a", "b", "c")
+        assert "_hash" not in p.__dict__
+        h = hash(p)
+        assert p.__dict__["_hash"] == h
+        # Subsequent calls return the same cached int without recomputing.
+        assert hash(p) == h
 
 
 class TestBasePathMakeChild:
@@ -695,8 +710,9 @@
     def test_type_sensitive_parts(self):
         assert BasePath(0) != BasePath("0")
 
-    def test_separator_part_of_equality(self):
-        assert BasePath("a", separator="/") != BasePath("a", separator=".")
+    def test_separator_not_part_of_equality(self):
+        # Address-only identity: separator is presentation, not identity.
+        assert BasePath("a", separator="/") == BasePath("a", separator=".")
 
 
 class TestBasePathLt:
@@ -731,9 +747,14 @@
         assert BasePath(0) < BasePath("0")
         assert not (BasePath("0") < BasePath(0))
 
-    def test_separator_affects_ordering(self):
-        # Separator is compared before parts.
-        assert BasePath("a", separator=".") < BasePath("a", separator="/")
+    def test_separator_does_not_affect_ordering(self):
+        # Ordering tracks identity: separator is not part of equality,
+        # so it is not part of ordering either. Two paths with the same
+        # parts compare as ordering-equivalent regardless of separator.
+        p1 = BasePath("a", separator=".")
+        p2 = BasePath("a", separator="/")
+        assert not (p1 < p2)
+        assert not (p2 < p1)
 
     def test_type_identifier_includes_module(self):
         # Two distinct types may share the same __qualname__.
@@ -1143,29 +1164,23 @@
 
         assert result == value
 
-    @pytest.mark.parametrize(
-        "resource,key,expected",
-        (
-            # returns value
-            [
-                {"test1": "test2"},
-                "test1",
-                "test2",
-            ],
-            # returns subpath
-            [
-                {"test1": {"test2": "test3"}},
-                "test1",
-                LookupPath._from_lookup(
-                    {"test1": {"test2": "test3"}}, "test1"
-                ),
-            ],
-        ),
-    )
-    def test_key_exists(self, resource, key, expected):
+    def test_key_exists_returns_leaf_value(self):
+        resource = {"test1": "test2"}
         p = LookupPath._from_lookup(resource)
 
-        result = p.get(key)
+        result = p.get("test1")
+
+        assert result == "test2"
+
+    def test_key_exists_returns_subpath(self):
+        # `expected` must share the same `resource` object: under the
+        # new accessor identity model, two LookupAccessors over
+        # value-equal but distinct dicts represent distinct resources.
+        resource = {"test1": {"test2": "test3"}}
+        p = LookupPath._from_lookup(resource)
+        expected = LookupPath._from_lookup(resource, "test1")
+
+        result = p.get("test1")
 
         assert result == expected
 
@@ -1230,26 +1245,20 @@
         assert excinfo.value.args == ("missing",)
 
     @pytest.mark.parametrize(
-        "resource,key,expected",
-        (
-            [
-                {"test1": "test2"},
-                "test1",
-                LookupPath._from_lookup({"test1": "test2"}, "test1"),
-            ],
-            [
-                {"test1": {"test2": "test3"}},
-                "test1",
-                LookupPath._from_lookup(
-                    {"test1": {"test2": "test3"}}, "test1"
-                ),
-            ],
-        ),
+        "resource",
+        [
+            {"test1": "test2"},
+            {"test1": {"test2": "test3"}},
+        ],
     )
-    def test_key_exists(self, resource, key, expected):
+    def test_key_exists(self, resource):
+        # Both `p` and `expected` must wrap the *same* `resource`
+        # object: under the new accessor identity model, two
+        # LookupAccessors over distinct dicts are distinct resources.
         p = LookupPath._from_lookup(resource)
+        expected = LookupPath._from_lookup(resource, "test1")
 
-        result = p // key
+        result = p // "test1"
 
         assert result == expected
 
@@ -1276,26 +1285,20 @@
         assert excinfo.value.args == ("missing",)
 
     @pytest.mark.parametrize(
-        "resource,key,expected",
-        (
-            [
-                {"test1": "test2"},
-                "test1",
-                LookupPath._from_lookup({"test1": "test2"}, "test1"),
-            ],
-            [
-                {"test1": {"test2": "test3"}},
-                "test1",
-                LookupPath._from_lookup(
-                    {"test1": {"test2": "test3"}}, "test1"
-                ),
-            ],
-        ),
+        "resource",
+        [
+            {"test1": "test2"},
+            {"test1": {"test2": "test3"}},
+        ],
     )
-    def test_key_exists(self, resource, key, expected):
+    def test_key_exists(self, resource):
+        # Both `p` and `expected` must wrap the *same* `resource`
+        # object: under the new accessor identity model, two
+        # LookupAccessors over distinct dicts are distinct resources.
         p = LookupPath._from_lookup(resource)
+        expected = LookupPath._from_lookup(resource, "test1")
 
-        result = key // p
+        result = "test1" // p
 
         assert result == expected
 
@@ -1630,6 +1633,171 @@
         assert p.__fspath__() == "a/b"
 
 
+class TestNodeAccessorIdentity:
+    """Locks in the per-accessor identity policy."""
+
+    def test_node_accessor_is_hashable(self):
+        # The base NodeAccessor must be hashable so it can participate
+        # in path identity tuples.
+        a = MockAccessor()
+        assert hash(a) == hash(a)
+        {a}  # construct a set; would raise if unhashable
+
+    def test_node_accessor_default_is_object_identity(self):
+        # The base NodeAccessor cannot know what makes a wrapped resource
+        # "the same"; default identity is the object itself.
+        a1 = MockAccessor()
+        a2 = MockAccessor()
+        assert a1 == a1
+        assert a1 != a2
+        assert hash(a1) == object.__hash__(a1)
+
+    def test_lookup_accessor_same_node_compares_equal(self):
+        # LookupAccessor identity = is-on-node: two LookupAccessors over
+        # the *same* dict object are interchangeable.
+        d = {"x": 1}
+        a1 = LookupAccessor(d)
+        a2 = LookupAccessor(d)
+        assert a1 == a2
+        assert hash(a1) == hash(a2)
+
+    def test_lookup_accessor_distinct_nodes_compare_unequal(self):
+        # Two LookupAccessors over value-equal but distinct dicts
+        # represent distinct resources (the user constructed each one;
+        # nothing ties them together).
+        a1 = LookupAccessor({"x": 1})
+        a2 = LookupAccessor({"x": 1})
+        assert a1 != a2
+
+    def test_lookup_accessor_different_class_not_equal(self):
+        class OtherLookup(LookupAccessor):
+            pass
+
+        d = {"x": 1}
+        assert LookupAccessor(d) != OtherLookup(d)
+
+    def test_path_accessor_value_equal_on_path(self):
+        # PathAccessor identity = value-equality on the wrapped Path
+        # (Path is hashable and value-equal on its canonical string),
+        # so two PathAccessors built from separately-constructed Paths
+        # pointing at the same location compare equal.
+        a1 = PathAccessor(Path("/tmp/x"))
+        a2 = PathAccessor(Path("/tmp/x"))
+        assert a1 == a2
+        assert hash(a1) == hash(a2)
+
+    def test_path_accessor_different_path_not_equal(self):
+        assert PathAccessor(Path("/tmp/x")) != PathAccessor(Path("/tmp/y"))
+
+    def test_path_accessor_different_class_not_equal(self):
+        class OtherPathAccessor(PathAccessor):
+            pass
+
+        p = Path("/tmp/x")
+        assert PathAccessor(p) != OtherPathAccessor(p)
+
+
+class TestPathIdentityCrossClass:
+    """BasePath and AccessorPath live in distinct equivalence classes."""
+
+    def test_basepath_not_equal_to_accessorpath(self):
+        accessor = LookupAccessor({"a": "b"})
+        assert BasePath("a") != LookupPath(accessor, "a")
+
+    def test_basepath_not_equal_to_accessorpath_reflected(self):
+        # Reflected dispatch must give the same answer.
+        accessor = LookupAccessor({"a": "b"})
+        assert LookupPath(accessor, "a") != BasePath("a")
+
+    def test_subclass_compares_equal_to_base_when_address_and_binding_match(
+        self,
+    ):
+        # Subclasses of AccessorPath that don't change identity semantics
+        # compare equal to the base (LSP). Class is not part of identity.
+        class MyLookupPath(LookupPath):
+            pass
+
+        accessor = LookupAccessor({"a": "b"})
+        assert LookupPath(accessor, "a") == MyLookupPath(accessor, "a")
+        assert hash(LookupPath(accessor, "a")) == hash(
+            MyLookupPath(accessor, "a")
+        )
+
+    def test_distinct_accessor_subclasses_not_equal(self):
+        # Different accessor backings = different resources.
+        # PathAccessor and LookupAccessor are never `==`.
+        lp = LookupPath.from_lookup({})
+        fp = FilesystemPath(PathAccessor(Path("/tmp")))
+        assert lp != fp
+
+    def test_accessorpath_ordering_is_address_based_across_bindings(self):
+        # Ordering is useful for presentation and stable sorting by
+        # address, but it is not a semantic identity check. Distinct
+        # bindings with the same parts are ordering-equivalent while
+        # remaining unequal.
+        p1 = LookupPath.from_lookup({"a": 1}, "a")
+        p2 = LookupPath.from_lookup({"a": 1}, "a")
+
+        assert p1 != p2
+        assert not (p1 < p2)
+        assert p1 <= p2
+        assert not (p1 > p2)
+        assert p1 >= p2
+
+
+class TestAccessorPathIsSameBinding:
+    def test_same_accessor_instance_is_same_binding(self):
+        accessor = LookupAccessor({"a": {"b": 1}})
+        p1 = LookupPath(accessor, "a")
+        p2 = LookupPath(accessor, "a")
+        assert p1.is_same_binding(p2)
+
+    def test_equal_accessors_distinct_instances_not_same_binding(self):
+        # Two LookupAccessors over the same dict are `==` (is-on-node),
+        # so the paths compare `==`, but they're not the same accessor
+        # *instance* — is_same_binding draws the stricter line.
+        d = {"a": {"b": 1}}
+        p1 = LookupPath(LookupAccessor(d), "a")
+        p2 = LookupPath(LookupAccessor(d), "a")
+        assert p1 == p2
+        assert not p1.is_same_binding(p2)
+
+    def test_is_same_binding_requires_accessorpath(self):
+        accessor = LookupAccessor({"a": "b"})
+        assert not LookupPath(accessor, "a").is_same_binding(BasePath("a"))
+
+
+class TestPathHashEqInvariant:
+    @pytest.mark.parametrize(
+        "a, b",
+        [
+            (BasePath("a", "b"), BasePath("a", "b")),
+            (BasePath("a", separator="/"), BasePath("a", separator=".")),
+        ],
+    )
+    def test_basepath_eq_implies_hash_eq(self, a, b):
+        assert a == b
+        assert hash(a) == hash(b)
+
+    def test_accessorpath_eq_implies_hash_eq(self):
+        accessor = LookupAccessor({"x": 1})
+        a = LookupPath(accessor, "x")
+        b = LookupPath(accessor, "x")
+        assert a == b
+        assert hash(a) == hash(b)
+
+    def test_accessorpath_eq_implies_hash_eq_across_accessor_instances(
+        self,
+    ):
+        # Two LookupAccessor instances over the same dict are == ; the
+        # paths over them must therefore be == and share a hash.
+        d = {"x": 1}
+        a = LookupPath(LookupAccessor(d), "x")
+        b = LookupPath(LookupAccessor(d), "x")
+        assert a == b
+        assert hash(a) == hash(b)
+
+
 class TestAccessorPathPathlibCompat:
     def test_parent_preserves_accessor(self):
         resource = {"a": {"b": {"c": "value"}}}

Reply via email to