# pathable

[![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 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., dicts/lists)
* Pluggable accessor layer for custom backends
* Pythonic, chainable API for concise and readable code
* Per-instance (bounded LRU) 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 LookupPath

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

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))

# Strict access (raises KeyError if missing)
must_exist = parts // "part2"

# "Open" yields the current value as a context manager
with parts.open() as parts_value:
    assert isinstance(parts_value, 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).
* Operations like `relative_to()` and `is_relative_to()` also respect the instance separator.

Equality and ordering:

* 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:

* `LookupPath` uses a per-instance LRU cache (default maxsize: 128) on its accessor.
* You can control it via `path.accessor.clear_cache()`, `path.accessor.disable_cache()`, and `path.accessor.enable_cache(maxsize=...)`.
* `path.accessor.node` is immutable; to point at a different tree, create a new `LookupPath`/accessor.

## Installation

Recommended way (via pip):

``` console
pip install pathable
```

Alternatively you can download the code and install from the repository:

``` console
pip install -e git+https://github.com/p1c2u/pathable.git#egg=pathable
```

## Benchmarks

Benchmarks live in `tests/benchmarks/` and produce JSON reports.

Local run (recommended as modules):

```console
poetry run python -m tests.benchmarks.bench_parse --output reports/bench-parse.json
poetry run python -m tests.benchmarks.bench_lookup --output reports/bench-lookup.json
```

Quick sanity run:

```console
poetry run python -m tests.benchmarks.bench_parse --quick --output reports/bench-parse.quick.json
poetry run python -m tests.benchmarks.bench_lookup --quick --output reports/bench-lookup.quick.json
```

Compare two results (fails if candidate is >20% slower in any scenario):

```console
poetry run python -m tests.benchmarks.compare_results \
    --baseline reports/bench-before.json \
    --candidate reports/bench-after.json \
    --tolerance 0.20
```

CI (on-demand):

- GitHub Actions workflow `Benchmarks` runs via `workflow_dispatch` and uploads the JSON artifacts.

