Skip to content
Closed
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
142 changes: 104 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,63 +1,129 @@
# pathable

<a href="https://pypi.python.org/pypi/pathable" target="_blank">
<img src="https://img.shields.io/pypi/v/pathable.svg" alt="Package version">
</a>
<a href="https://travis-ci.org/p1c2u/pathable" target="_blank">
<img src="https://travis-ci.org/p1c2u/pathable.svg?branch=master" alt="Continuous Integration">
</a>
<a href="https://codecov.io/github/p1c2u/pathable?branch=master" target="_blank">
<img src="https://img.shields.io/codecov/c/github/p1c2u/pathable/master.svg?style=flat" alt="Tests coverage">
</a>
<a href="https://pypi.python.org/pypi/pathable" target="_blank">
<img src="https://img.shields.io/pypi/pyversions/pathable.svg" alt="Python versions">
</a>
<a href="https://pypi.python.org/pypi/pathable" target="_blank">
<img src="https://img.shields.io/pypi/format/pathable.svg" alt="Package format">
</a>
<a href="https://pypi.python.org/pypi/pathable" target="_blank">
<img src="https://img.shields.io/pypi/status/pathable.svg" alt="Development status">
</a>
[![Package version](https://img.shields.io/pypi/v/pathable.svg)](https://pypi.org/project/pathable/)
[![Python versions](https://img.shields.io/pypi/pyversions/pathable.svg)](https://pypi.org/project/pathable/)
[![License](https://img.shields.io/pypi/l/pathable.svg)](https://pypi.org/project/pathable/)

## About

Pathable provides a flexible, object-oriented interface for traversing and manipulating hierarchical data structures (such as lists or dictionaries) using path-like syntax. It enables intuitive navigation, access, and modification of nested resources in Python.
Pathable provides a small set of "path" objects for traversing hierarchical data (mappings, lists, and other subscriptable trees) using a familiar path-like syntax.

It’s especially handy when you want to:

* express deep lookups as a single object (and pass it around)
* build paths incrementally (`p / "a" / 0 / "b"`)
* safely probe (`exists()`, `get(...)`) or strictly require segments (`//`)

## Key features

* Intuitive path-based navigation for nested data (e.g., lists, dicts)
* Pluggable accessor layer for custom data sources or backends
* Intuitive path-based navigation for nested data (e.g., dicts/lists)
* Pluggable accessor layer for custom backends
* Pythonic, chainable API for concise and readable code
* Cached lookup accessor for repeated reads of the same tree

## Quickstart

```python
from pathable import LookupPath

data = {
"parts": {
"part1": {"name": "Part One"},
"part2": {"name": "Part Two"},
}
}

root = LookupPath.from_lookup(data)

name = (root / "parts" / "part2" / "name").read_value()
assert name == "Part Two"
```

## Usage

```python
from pathable import DictPath
from pathable import LookupPath

d = {
data = {
"parts": {
"part1": {
"name": "Part One",
},
"part2": {
"name": "Part Two",
},
},
"part1": {"name": "Part One"},
"part2": {"name": "Part Two"},
}
}

dp = DictPath(d)
p = LookupPath.from_lookup(data)

# Concatenate path segments with /
parts = p / "parts"

# Check membership (mapping keys or list indexes)
assert "part2" in parts

# Read a value
assert (parts / "part2" / "name").read_value() == "Part Two"

# Iterate children as paths
for child in parts:
print(child, child.read_value())

# Work with keys/items
print(list(parts.keys()))
print({k: v.read_value() for k, v in parts.items()})

# Safe access
print(parts.get("missing", default=None))

# Concatenate paths with /
parts = dp / "parts"
# Strict access (raises KeyError if missing)
must_exist = parts // "part2"

# Stat path keys
"part2" in parts
# "Open" yields the current value as a context manager
with parts.open() as parts_value:
assert isinstance(parts_value, dict)

# Open path dict
with parts.open() as parts_dict:
print(parts_dict)
# Optional metadata
print(parts.stat())
```

## Filesystem example

Pathable can also traverse the filesystem via an accessor.

```python
from pathlib import Path

from pathable import FilesystemPath

root_dir = Path(".")
p = FilesystemPath.from_path(root_dir)

readme = p / "README.md"
if readme.exists():
content = readme.read_value() # bytes
print(content[:100])
```

## Core concepts

* `BasePath` is a pure path (segments + separator) with `/` joining.
* `AccessorPath` is a `BasePath` bound to a `NodeAccessor`, enabling `read_value()`, `exists()`, `keys()`, iteration, etc.
* `FilesystemPath` is an `AccessorPath` specialized for filesystem objects.
* `LookupPath` is an `AccessorPath` specialized for mapping/list lookups.

Notes on parsing:

* A segment like `"a/b"` is split into parts using the separator.
* `None` segments are ignored.
* `"."` segments are ignored (relative no-op).

## Typing & compatibility

* Python: `>=3.9,<4.0`
* Lookup keys are `str | int` (use `int` for list indexes).

## Performance notes

`LookupPath` uses a cached accessor for reads. If you repeatedly read the same path on the same tree, it will not re-traverse the structure.

## Installation

Recommended way (via pip):
Expand Down
6 changes: 6 additions & 0 deletions pathable/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Pathable module"""
from pathable.accessors import NodeAccessor
from pathable.accessors import PathAccessor
from pathable.paths import AccessorPath
from pathable.paths import BasePath
from pathable.paths import FilesystemPath
from pathable.paths import LookupPath
from pathable.paths import LookupPath as DictPath
from pathable.paths import LookupPath as ListPath
Expand All @@ -14,7 +17,10 @@
__all__ = [
"BasePath",
"AccessorPath",
"FilesystemPath",
"LookupPath",
"DictPath",
"ListPath",
"NodeAccessor",
"PathAccessor",
]
38 changes: 37 additions & 1 deletion pathable/paths.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Pathable paths module"""
import warnings
from collections.abc import Hashable
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import Any
from typing import Generic
from typing import Optional
Expand All @@ -15,6 +15,7 @@

from pathable.accessors import NodeAccessor
from pathable.accessors import LookupAccessor, N, K, V
from pathable.accessors import PathAccessor
from pathable.parsers import SEPARATOR
from pathable.parsers import parse_args
from pathable.types import LookupKey
Expand Down Expand Up @@ -235,10 +236,45 @@ def read_value(self) -> V:
"""Return the path's value."""
return self.accessor.read(self.parts)

def stat(self) -> Union[dict[str, Any], None]:
"""Return metadata for the path, or None if it doesn't exist."""
return self.accessor.stat(self.parts)

@contextmanager
def open(self) -> Iterator[V]:
"""Context manager that yields the current path's value.

This mirrors a file-like "open" API but works for any accessor.
"""
yield self.read_value()


class FilesystemPath(AccessorPath[Path, str, bytes]):
"""Path for filesystem objects."""

@classmethod
def from_path(
cls: Type["FilesystemPath"],
path: Path,
) -> "FilesystemPath":
"""Public constructor for a Path-backed path."""
accessor = PathAccessor(path)
return cls(accessor)


class LookupPath(AccessorPath[LookupNode, LookupKey, LookupValue]):
"""Path for object that supports __getitem__ lookups."""

@classmethod
def from_lookup(
cls: type["LookupPath"],
lookup: LookupNode,
*args: Any,
**kwargs: Any,
) -> "LookupPath":
"""Public constructor for a lookup-backed path."""
return cls._from_lookup(lookup, *args, **kwargs)

@classmethod
def _from_lookup(
cls: type["LookupPath"],
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -1071,3 +1071,29 @@ def test_content_cached(self):
assert resource.getitem_counter == 1
assert result == p.read_value()
assert resource.getitem_counter == 1


class TestLookupPathFromLookup:
def test_from_lookup_matches_private_constructor(self):
resource = {"test1": {"test2": "test3"}}

p1 = LookupPath._from_lookup(resource, "test1")
p2 = LookupPath.from_lookup(resource, "test1")

assert p1 == p2
assert p1.read_value() == p2.read_value()


class TestAccessorPathOpenAndStat:
def test_open_yields_read_value(self):
resource = {"test1": {"test2": "value"}}
p = LookupPath.from_lookup(resource, "test1")

with p.open() as value:
assert value == {"test2": "value"}

def test_stat_returns_none_for_missing(self):
resource = {"test1": {"test2": "value"}}
p = LookupPath.from_lookup(resource, "test1", "missing")

assert p.stat() is None
Loading