Skip to content
Merged
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
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
37 changes: 36 additions & 1 deletion pathable/accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
96 changes: 75 additions & 21 deletions pathable/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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]):
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading