diff --git a/README.md b/README.md index bc1b830..74cd7f6 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,30 @@ Notes on parsing: 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 --git a/pathable/accessors.py b/pathable/accessors.py index d93236b..fdab3ca 100644 --- a/pathable/accessors.py +++ b/pathable/accessors.py @@ -40,7 +40,16 @@ def __getitem__(self, parts: Sequence[K]) -> N: 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 @@ def _get_subnode(cls, node: N, part: K) -> N: 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 @@ def read(self, parts: Sequence[CSK]) -> CSV: 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 --git a/pathable/paths.py b/pathable/paths.py index 39b61ce..af5ff80 100644 --- a/pathable/paths.py +++ b/pathable/paths.py @@ -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 __str__(self) -> str: 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 @@ def __rtruediv__(self: TBasePath, key: Hashable) -> TBasePath: 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 @@ def _clone_with_parts( 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 --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py index 6d70196..f602259 100644 --- a/tests/unit/test_paths.py +++ b/tests/unit/test_paths.py @@ -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 @@ class TestBasePathHash: 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 @@ def test_cparts_cached(self): # 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_not_implemented(self): 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 @@ def test_mixed_type_ordering_is_deterministic(self): 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 @@ def test_non_existing_key_default_defined(self): 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("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(key) + result = p.get("test1") assert result == expected @@ -1230,26 +1245,20 @@ def test_missing_intermediate_raises_missing_segment(self): 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 @@ def test_missing_intermediate_raises_missing_segment(self): 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 @@ def test_as_posix_and_fspath(self): 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"}}}