"""Pathable accessors module""" import stat from collections import OrderedDict from collections.abc import Hashable from collections.abc import Mapping from collections.abc import Sequence from pathlib import Path from typing import Any from typing import Generic from typing import TypeVar from pathable.protocols import Subscriptable from pathable.types import LookupKey from pathable.types import LookupNode from pathable.types import LookupValue K = TypeVar("K", bound=Hashable, contravariant=True) V = TypeVar("V", covariant=True) N = TypeVar("N") SK = TypeVar("SK", bound=Hashable, contravariant=True) SV = TypeVar("SV", covariant=True) CSK = TypeVar("CSK", bound=Hashable, contravariant=True) CSV = TypeVar("CSV", covariant=True) class NodeAccessor(Generic[N, K, V]): """Node accessor.""" def __init__(self, node: N): self._node = node @property def node(self) -> N: return self._node def __getitem__(self, parts: Sequence[K]) -> N: return self._get_node(self.node, parts) def __eq__(self, other: object) -> Any: if not isinstance(other, NodeAccessor): return NotImplemented # 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 def keys(self, parts: Sequence[K]) -> Sequence[K]: """Return the keys of the node at `parts` if it is traversable, or raise `KeyError` if not. This performs a segment-by-segment traversal and raises `KeyError` with the failing segment if any part is missing or non-traversable. """ raise NotImplementedError def is_traversable(self, parts: Sequence[K]) -> bool: """Return True if the node at `parts` can enumerate child keys. This is intended for control-flow ("can I call keys()/len()/iterate?") and must not raise for missing or non-traversable paths, but may raise OSError for permission or I/O errors. The default implementation attempts a cheap node inspection via `_is_traversable_node` after traversing to the node. If that is not implemented, it falls back to calling `keys()`. Note: the fallback may be expensive for accessors where `keys()` enumerates large containers. """ try: node = self[parts] except KeyError: return False except NotImplementedError: try: self.keys(parts) except (KeyError, IndexError, TypeError, NotImplementedError): return False return True try: return self._is_traversable_node(node) except NotImplementedError: try: self.keys(parts) except (KeyError, IndexError, TypeError, NotImplementedError): return False return True def contains(self, parts: Sequence[K], key: K) -> bool: """Return True if `key` is a valid child of the node at `parts`. The default implementation tries to validate membership by traversing a single step (fast for accessors that implement `_get_subnode`). If traversal isn't available, it falls back to `keys()` for compatibility with accessors that only define enumeration. This method is intended to be used for membership checks (e.g. `key in path`) where errors should not be raised. """ try: parent = self[parts] try: self._get_subnode(parent, key) except (KeyError, IndexError, TypeError): return False return True except KeyError: return False except NotImplementedError: try: return key in self.keys(parts) except (KeyError, IndexError, TypeError): return False def require_child(self, parts: Sequence[K], key: K) -> None: """Assert that `key` is a valid child of the node at `parts`. Raises `KeyError` with stable diagnostics. """ try: # Validate the parent first to preserve intermediate segment # diagnostics. parent = self[parts] try: self._get_subnode(parent, key) return except KeyError as exc: raise KeyError(key) from exc except NotImplementedError: keys = self.keys(parts) if key not in keys: raise KeyError(key) def len(self, parts: Sequence[K]) -> int: raise NotImplementedError def read(self, parts: Sequence[K]) -> V: node = self[parts] return self._read_node(node) def validate(self, parts: Sequence[K]) -> None: """Validate that the node at `parts` exists. This performs a traversal only and raises `KeyError` (with the failing part when available) if the path is missing or non-traversable. """ self[parts] @classmethod def _is_traversable_node(cls, node: N) -> bool: raise NotImplementedError @classmethod def _get_node(cls, node: N, parts: Sequence[K]) -> N: current = node get_subnode = cls._get_subnode for part in parts: current = get_subnode(current, part) return current @classmethod def _read_node(cls, node: N) -> V: raise NotImplementedError @classmethod def _get_subnode(cls, node: N, part: K) -> N: raise NotImplementedError 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: stat = subpath.stat(follow_symlinks=False) except OSError: return None return { key: getattr(stat, key) for key in dir(stat) if key.startswith("st_") } def keys(self, parts: Sequence[str]) -> Sequence[str]: # Traverse using `get()` so missing intermediate segments are # reported by `_get_subnode()` with the first failing part. subpath = self[parts] try: return [path.name for path in subpath.iterdir()] except (FileNotFoundError, NotADirectoryError) as exc: if parts: raise KeyError(parts[-1]) from exc raise KeyError from exc @classmethod def _is_traversable_node(cls, node: Path) -> bool: # Avoid following symlinks for consistency with stat() # Use lstat to check the symlink itself, not its target try: return stat.S_ISDIR(node.lstat().st_mode) except OSError: return False def contains(self, parts: Sequence[str], key: str) -> bool: try: subpath = self[parts] except KeyError: return False return (subpath / key).exists() def require_child(self, parts: Sequence[str], key: str) -> None: subpath = self[parts] if not subpath.is_dir(): if parts: raise KeyError(parts[-1]) raise KeyError child = subpath / key if not child.exists(): raise KeyError(key) def len(self, parts: Sequence[str]) -> int: # Traverse using `get()` so missing intermediate segments are # reported by `_get_subnode()` with the first failing part. subpath = self[parts] try: return sum(1 for _ in subpath.iterdir()) except (FileNotFoundError, NotADirectoryError) as exc: if parts: raise KeyError(parts[-1]) from exc raise KeyError from exc def read(self, parts: Sequence[str]) -> bytes: node = self[parts] return self._read_node(node) @classmethod def _read_node(cls, node: Path) -> bytes: return node.read_bytes() @classmethod def _get_subnode(cls, node: Path, part: str) -> Path: subnode = node / part if not subnode.exists(): raise KeyError(part) return subnode class SubscriptableAccessor( NodeAccessor[Subscriptable[SK, SV] | SV, SK, SV], Generic[SK, SV] ): """Accessor for subscriptable content.""" @classmethod def _get_subnode( cls, node: Subscriptable[SK, SV] | SV, part: SK ) -> Subscriptable[SK, SV] | SV: if not isinstance(node, Subscriptable): raise KeyError(part) try: return node[part] except (KeyError, IndexError, TypeError) as exc: raise KeyError(part) from exc class CachedSubscriptableAccessor( SubscriptableAccessor[CSK, CSV], Generic[CSK, CSV] ): def __init__(self, node: Subscriptable[CSK, CSV] | CSV): super().__init__(node) # Per-instance cache: avoids global strong references and id-reuse hazards. # Default maxsize matches functools.lru_cache default (128). self._cache_enabled = True self._cache_maxsize: int | None = 128 self._cache: OrderedDict[tuple[CSK, ...], CSV] = OrderedDict() def clear_cache(self) -> None: """Clear any cached reads for this accessor instance.""" self._cache.clear() def disable_cache(self) -> None: """Disable caching for this accessor instance.""" self._cache_enabled = False self._cache.clear() def enable_cache(self, *, maxsize: int | None = 128) -> None: """Enable caching for this accessor instance. Args: maxsize: Maximum number of distinct paths to cache. - 128 by default (matches functools.lru_cache) - None for unbounded - 0 to disable caching """ self._cache_enabled = True self._cache_maxsize = maxsize self._cache.clear() def read(self, parts: Sequence[CSK]) -> CSV: key = tuple(parts) if (not self._cache_enabled) or self._cache_maxsize == 0: node = self[parts] return self._read_node(node) try: value = self._cache[key] except KeyError: node = self[parts] value = self._read_node(node) self._cache[key] = value else: # Mark as recently used. self._cache.move_to_end(key) return value # Enforce max size (LRU eviction). if self._cache_maxsize is not None: while len(self._cache) > self._cache_maxsize: self._cache.popitem(last=False) return value 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) def stat(self, parts: Sequence[LookupKey]) -> dict[str, Any] | None: try: node = self[parts] except KeyError: return None length: int | None match node: case Mapping() | list(): length = len(node) case _: try: length = len(node) except TypeError: length = None return { "type": type(node).__name__, "length": length, } def contains(self, parts: Sequence[LookupKey], key: LookupKey) -> bool: try: node = self[parts] except KeyError: return False match node: case Mapping(): return key in node case list() as items: return isinstance(key, int) and 0 <= key < len(items) case _: return False def require_child( self, parts: Sequence[LookupKey], key: LookupKey ) -> None: # Validate parent path for intermediate diagnostics. node = self[parts] match node: case Mapping(): if key not in node: raise KeyError(key) return case list() as items: if not (isinstance(key, int) and 0 <= key < len(items)): raise KeyError(key) return case _: raise KeyError(key) def keys(self, parts: Sequence[LookupKey]) -> Sequence[LookupKey]: node = self[parts] match node: case Mapping(): return list(node.keys()) case list() as items: return list(range(len(items))) # Non-traversable leaf. if parts: raise KeyError(parts[-1]) raise KeyError def len(self, parts: Sequence[LookupKey]) -> int: node = self[parts] # Define length as the number of child paths (consistent with keys()). if self._is_traversable_node(node): return len(node) # Non-traversable leaf. if parts: raise KeyError(parts[-1]) raise KeyError @classmethod def _read_node(cls, node: LookupNode) -> LookupValue: return node