Metadata-Version: 2.4
Name: path-seeker
Version: 0.4.0
Summary: Find, count, and update keys anywhere in a nested dict or list of dicts, with full path disambiguation.
Project-URL: Homepage, https://github.com/rhasan33/path-seeker
Project-URL: Issues, https://github.com/rhasan33/path-seeker/issues
Project-URL: Changelog, https://github.com/rhasan33/path-seeker/blob/main/CHANGELOG.md
Author-email: Rakib Hasan Amiya <rhasan.amiya@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: dict,json,nested,path,search
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pre-commit; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Description-Content-Type: text/markdown

# path-seeker

[![CI](https://github.com/rhasan33/path-seeker/actions/workflows/ci.yml/badge.svg)](https://github.com/rhasan33/path-seeker/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/path-seeker.svg)](https://pypi.org/project/path-seeker/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Find, count, and update keys anywhere in a nested `dict` or `list` of dicts — with
full path disambiguation when the same key occurs at multiple depths.

## Installation

```bash
pip install path-seeker
```

## Usage

```python
from path_seeker import PathSeeker

data = {
    "user": {
        "id": 1,
        "address": {"id": 42, "city": "Berlin"},
    },
    "orders": [
        {"id": 100, "status": "shipped"},
        {"id": 101, "status": "pending"},
    ],
}

seeker = PathSeeker(data)

# All values found under a key, at any depth
seeker.find_values("id")
# [1, 42, 100, 101]

# All (path, value) matches — use this to disambiguate duplicates
seeker.find_paths("id")
# [Match(path=['user', 'id'], value=1),
#  Match(path=['user', 'address', 'id'], value=42),
#  Match(path=['orders', 0, 'id'], value=100),
#  Match(path=['orders', 1, 'id'], value=101)]

# The first/last value found under a key, in traversal order
seeker.find_first_value("id")
# 1
seeker.find_last_value("id")
# 101

# The first/last (path, value) match found under a key, in traversal order
seeker.find_first_path("id")
# Match(path=['user', 'id'], value=1)
seeker.find_last_path("id")
# Match(path=['orders', 1, 'id'], value=101)

# Every distinct key name in the structure
seeker.get_keys()
# ['address', 'city', 'id', 'orders', 'status', 'user']

# How many times a key occurs
seeker.count_key("id")
# 4

# Whether a key occurs anywhere
seeker.has_key("id")
# True

# Update one exact occurrence, using a path from find_paths
seeker.set_value(["orders", 1, "status"], "cancelled")

# Update every occurrence of a key at once
seeker.update_values("id", 0)
# 4  (number of occurrences updated)

# Update just the first/last occurrence, in traversal order
seeker.update_first("id", -1)
# True
seeker.update_last("id", -2)
# True

# Delete every occurrence of a key
seeker.delete_key("status")
# 2  (number of occurrences deleted)

# Delete just the first/last occurrence, in traversal order
seeker.delete_first("id")
# True
seeker.delete_last("id")
# True
```

## API

| Method | Returns | Description |
| --- | --- | --- |
| `find_values(key)` | `list[Any]` | Every value stored under `key`, at any depth. |
| `find_paths(key)` | `list[Match]` | Every `(path, value)` pair for `key`, at any depth. |
| `find_first_value(key)` | `Any` | The first value found under `key`, in traversal order. Raises `UnknownKeyProvided` if `key` doesn't occur anywhere. |
| `find_last_value(key)` | `Any` | The last value found under `key`, in traversal order. Raises `UnknownKeyProvided` if `key` doesn't occur anywhere. |
| `find_first_path(key)` | `Match` | The first `(path, value)` match found under `key`, in traversal order. Raises `UnknownKeyProvided` if `key` doesn't occur anywhere. |
| `find_last_path(key)` | `Match` | The last `(path, value)` match found under `key`, in traversal order. Raises `UnknownKeyProvided` if `key` doesn't occur anywhere. |
| `get_keys()` | `list[str]` | Every distinct key name in the structure, sorted. |
| `count_key(key)` | `int` | How many times `key` occurs. |
| `has_key(key)` | `bool` | Whether `key` occurs anywhere in the structure. |
| `set_value(path, new_value)` | `None` | Update the value at an exact path (from `find_paths`). |
| `update_values(key, new_value)` | `int` | Update every occurrence of `key`; returns count updated. |
| `update_first(key, new_value)` | `bool` | Update the first occurrence of `key`, in traversal order; `False` if absent. |
| `update_last(key, new_value)` | `bool` | Update the last occurrence of `key`, in traversal order; `False` if absent. |
| `delete_key(key)` | `int` | Delete every occurrence of `key`, at any depth; returns count deleted. |
| `delete_first(key)` | `bool` | Delete the first occurrence of `key`, in traversal order; `False` if absent. |
| `delete_last(key)` | `bool` | Delete the last occurrence of `key`, in traversal order; `False` if absent. |
| `.data` | `dict \| list` | The current (possibly mutated) underlying structure. |

Search is by **key name**, not by value — searching for a value and getting its path
back is not supported yet.

`find_first_*`/`find_last_*` raise `UnknownKeyProvided` when `key` doesn't occur
anywhere; `update_first`/`update_last`/`delete_first`/`delete_last`/`delete_key`
return `False`/`0` instead, since "the key wasn't there" is an expected outcome for
a write, not an error.

## Limitations

- `PathSeeker` holds a reference to the structure you pass in — it does not make a
  defensive copy, so updates via `set_value`/`update_values` mutate your original
  object too.
- Nested `dict`s and `list`s are traversed; other iterables (tuples, sets,
  generators) are treated as opaque values, since list-index-style paths don't make
  sense for them.
- No cycle detection. This is fine for JSON/YAML-shaped data, which can't contain
  cycles, but a structure with a manually-introduced reference cycle will recurse
  forever.
- `count_key`, `has_key`, and `get_keys` do a full traversal on every call — there's
  no indexing or caching, so repeated calls on a large structure cost proportionally
  more each time.

## Development

```bash
make install      # uv sync --all-extras
make check        # lint + typecheck + test (what CI runs)
```

Individual targets: `make test`, `make lint`, `make format`, `make typecheck`.

See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow.

## License

MIT — see [LICENSE](LICENSE).
