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"}}}