Metadata-Version: 2.4
Name: deepfinder
Version: 1.6.0
Summary: Search attributes easily using dot paths. Within structures of type dictionary, list and embedded substructures with simple format 'dict.users.0.name'.
Author-email: Javier Parada <javierparada@pm.me>
License-Expression: MIT
Project-URL: Homepage, https://github.com/otsobide/deepfinder.py
Project-URL: Repository, https://github.com/otsobide/deepfinder.py
Project-URL: Issues, https://github.com/otsobide/deepfinder.py/issues
Project-URL: Changelog, https://github.com/otsobide/deepfinder.py/blob/main/CHANGELOG.md
Keywords: array,deep,dictionary,find,get,list,nested,path,structure
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
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: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Utilities
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: coverage>=7.0; extra == "dev"
Requires-Dist: mypy>=1.8; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Dynamic: license-file

<h1 align="center">🔍 Deepfinder</h1>

<div align="center">

[![Tests](https://github.com/otsobide/deepfinder.py/actions/workflows/tests.yml/badge.svg)](https://github.com/otsobide/deepfinder.py/actions/workflows/tests.yml)
[![Pypi](https://img.shields.io/pypi/v/deepfinder)](https://pypi.org/project/deepfinder/)
[![Python](https://img.shields.io/pypi/pyversions/deepfinder)](https://pypi.org/project/deepfinder/)
[![Downloads](https://pepy.tech/badge/deepfinder)](https://pepy.tech/project/deepfinder)
[![License](https://img.shields.io/github/license/otsobide/deepfinder.py)](https://github.com/otsobide/deepfinder.py/blob/main/LICENSE)

</div>

## What is Deepfinder?

Deepfinder reads values out of nested data using a dot path. Instead of a ladder of
`if` statements and `.get()` calls, you write the shape of what you want:

```python
>>> from deepfinder import deep_find
>>> user = {'name': 'ash', 'links': {'pokehub': '@ash'}}
>>> deep_find(user, 'links.pokehub')
'@ash'

```

It has no dependencies, ships type hints, and supports Python 3.9+.

Every example in this file is executed as part of the test suite, so nothing here
can drift away from what the code actually does.

### Key features

- **Dot paths** into dictionaries, sequences and objects: `'user.profile.name'`
- **Indexing**, including from the end: `'users.0.name'`, `'users.-1.name'`
- **Fan-out** over sequences: `'users.*.name'`
- **Null handling**: `'users.?.email'` for the first hit, `'users.*?.email'` for all of them
- **Never raises** on a lookup: a miss yields `default`
- **Container subclasses** that carry the method with them

## Why use it?

Reading one optional field out of a real API response is mostly defensive
plumbing. Say you want every non-empty email in a paginated payload:

```python
>>> response = {
...     'data': {
...         'users': [
...             {'profile': {'contact': {'email': 'ash@pallet.town'}}},
...             {'profile': {'contact': {}}},
...             {'profile': {'contact': {'email': 'misty@cerulean.city'}}},
...         ],
...     },
... }

```

By hand, every level needs a guard, because any of them can be missing:

```python
>>> emails = []
>>> for user in response.get('data', {}).get('users', []):
...     email = user.get('profile', {}).get('contact', {}).get('email')
...     if email is not None:
...         emails.append(email)
>>> emails
['ash@pallet.town', 'misty@cerulean.city']

```

The same thing as a path:

```python
>>> deep_find(response, 'data.users.*?.profile.contact.email')
['ash@pallet.town', 'misty@cerulean.city']

```

## Contents

- [Installation](#installation)
- [Path syntax](#path-syntax)
- [Quick start](#quick-start)
- [API reference](#api-reference)
- [Behaviour worth knowing](#behaviour-worth-knowing)
- [Paths and untrusted input](#paths-and-untrusted-input)
- [Deprecated: `nativify()`](#deprecated-nativify)
- [Development](#development)
- [Contributing](#contributing)

## Installation

```bash
pip install deepfinder
```

No dependencies, on any supported Python. The package ships a `py.typed` marker,
so type checkers pick up its annotations with no stub package.

## Path syntax

| Segment | Meaning | Example |
| --- | --- | --- |
| `name` | Dictionary key, mapping key, or object attribute | `'user.name'` |
| `0`, `-1` | Sequence index, negative counts from the end | `'users.0.name'` |
| `*` | Every item, one result per item | `'users.*.name'` |
| `?` | The first item that resolves to a non-`None` value | `'users.?.email'` |
| `*?` | Every item that resolves to a non-`None` value (`?*` also works) | `'users.*?.email'` |

The separator is configurable with `path_token`, which is also how you reach keys
that contain a dot:

```python
>>> deep_find({'a.b': {'c': 1}}, 'a.b/c', path_token='/')
1

```

## Quick start

### Dictionaries and lists

```python
>>> trainer = {
...     'name': 'ash',
...     'pokemons': [
...         {'name': 'pikachu', 'type': 'electric'},
...         {'name': 'charmander', 'type': 'fire'},
...     ],
... }
>>> deep_find(trainer, 'pokemons.0.name')
'pikachu'
>>> deep_find(trainer, 'pokemons.-1.name')
'charmander'
>>> deep_find(trainer, 'pokemons.*.name')
['pikachu', 'charmander']

```

### Missing values

A lookup never raises. When it does not resolve, you get `default`:

```python
>>> deep_find(trainer, 'pokemons.99.name') is None
True
>>> deep_find(trainer, 'pokemons.99.name', default='unknown')
'unknown'

```

### First hit, and all the hits

```python
>>> squad = {
...     'pokemons': [
...         {'name': 'pikachu'},
...         {'name': 'charmander', 'ball': 'superball'},
...         {'name': 'lucario', 'ball': 'ultraball'},
...     ],
... }
>>> deep_find(squad, 'pokemons.?.ball')
'superball'
>>> deep_find(squad, 'pokemons.*?.ball')
['superball', 'ultraball']

```

`*` keeps one slot per item, so it tells you *which* items missed:

```python
>>> deep_find(squad, 'pokemons.*.ball')
[None, 'superball', 'ultraball']

```

### Objects

Instance attributes, `__slots__`, class attributes and properties all resolve:

```python
>>> class Address:
...     def __init__(self, city):
...         self.city = city
>>> class Trainer:
...     region = 'Kanto'
...     def __init__(self, name, address):
...         self.name = name
...         self.address = address
...     @property
...     def display_name(self):
...         return self.name.title()
>>> ash = Trainer('ash', Address('Pallet Town'))
>>> deep_find(ash, 'address.city')
'Pallet Town'
>>> deep_find(ash, 'display_name')
'Ash'
>>> deep_find(ash, 'region')
'Kanto'

```

Methods are not values, so a segment that collides with a method name misses rather
than handing back a bound method:

```python
>>> deep_find(ash, 'display_name.upper', default='not found')
'not found'

```

Named tuples resolve both ways:

```python
>>> from collections import namedtuple
>>> Point = namedtuple('Point', ['x', 'y'])
>>> deep_find({'p': Point(1, 2)}, 'p.y')
2
>>> deep_find({'p': Point(1, 2)}, 'p.0')
1

```

### Mappings

Anything that is a `Mapping` resolves by key, not just `dict`:

```python
>>> from collections import ChainMap
>>> deep_find(ChainMap({'a': 1}, {'b': 2}), 'b')
2

```

### Containers that carry the method

```python
>>> from deepfinder.entity import DeepFinderDict, DeepFinderList
>>> DeepFinderDict(squad).deep_find('pokemons.?.ball')
'superball'
>>> DeepFinderList([squad]).deep_find('0.pokemons.*?.ball')
['superball', 'ultraball']

```

Both accept the same `path_token` and `default` arguments as `deep_find`.

## API reference

### `deep_find(obj, path, path_token='.', default=None)`

| Argument | Default | What it does |
| --- | --- | --- |
| `obj` | — | The structure to search: a dictionary, any `Mapping`, any non-string iterable, or an object |
| `path` | — | The path, e.g. `'users.0.name'`. An empty path returns `obj` unchanged |
| `path_token` | `'.'` | The separator between segments. Any string, not only one character |
| `default` | `None` | Returned whenever the path resolves to `None` |

Returns the resolved value, or `default`.

Raises `TypeError` if `path` is not a string and `ValueError` if `path_token` is
empty. Nothing else: a path that cannot be resolved is a miss, not an error.

### `DeepFinderDict.deep_find(path, path_token='.', default=None)`

### `DeepFinderList.deep_find(path, path_token='.', default=None)`

The same arguments and the same semantics, with the container itself as `obj`.
Both classes are generic, so `DeepFinderList[int]` and `DeepFinderDict[str, int]`
keep their element types through a type checker:

```python
>>> numbers: DeepFinderList[int] = DeepFinderList([1, 2, 3])
>>> numbers.deep_find('-1')
3

```

## Behaviour worth knowing

These are the sharp edges, all of them covered by tests.

| Situation | Result | Why |
| --- | --- | --- |
| The stored value is `None` | `default` | A resolved `None` is indistinguishable from a miss |
| `*` or `*?` with `default` set | `[...]`, never `default` | A list is never `None`, so substitution cannot fire |
| Falsy values (`0`, `''`, `False`, `[]`) | returned as-is | Substitution keys off `None`, not truthiness |
| A key containing the separator | miss | Use a different `path_token` |
| Strings | not indexable | So a path never walks into single characters |
| `bytes` / `bytearray` | indexed as integers | They are ordinary non-string iterables |
| Methods | never resolve | So `'count'` or `'items'` yields `default`, not a truthy bound method |
| Callables held as instance state | resolve | They are data the object is carrying |
| Generators and iterators | advanced only as far as the index needs | The fan-out operators still read all of it |
| Large sequences such as `range` | indexed in place, never copied | `deep_find(range(10 ** 10), '3')` is instant |
| Sets and frozen sets | indexable, order not guaranteed | Materialised in iteration order |

### Paths and untrusted input

`deep_find` walks data, not the interpreter. Dunder segments never resolve, and
attributes are never read off modules, functions, frames, tracebacks, coroutines or
code objects, and methods do not resolve. A path therefore cannot pivot from your
data into module globals or frame locals:

```python
>>> deep_find(ash, '__class__')  is None
True
>>> deep_find(ash, 'display_name.__globals__') is None
True

```

That said, `deep_find` will happily return any value your own object graph exposes.
If paths come from users, keep deciding for yourself which roots you hand it.

### Argument validation

Misuse of the API is loud, unlike a lookup that simply misses:

```python
>>> deep_find({'a': 1}, 1)
Traceback (most recent call last):
    ...
TypeError: path must be a str, got int
>>> deep_find({'a': 1}, 'a', path_token='')
Traceback (most recent call last):
    ...
ValueError: path_token must not be empty

```

## Deprecated: `nativify()`

`deepfinder.entity.nativify()` rebinds `builtins.list` and `builtins.dict` so that
containers built through those *constructors* gain a `deep_find` method. It is
deprecated as of 1.6.0: it mutates the interpreter for every library in the process,
and it never affected list and dict **literals**, which are built by bytecode that
does not consult `builtins`. Use `DeepFinderList` / `DeepFinderDict`, or just call
`deep_find`.

## Development

```bash
git clone https://github.com/otsobide/deepfinder.py
cd deepfinder.py
make install    # installs the package plus the dev extras
make check      # lint, format check, type check, tests with coverage
```

Individual targets:

```bash
make lint       # ruff check
make format     # ruff format
make typecheck  # mypy --strict
make test       # unittest
make coverage   # unittest under coverage, fails under 100%
make build      # sdist + wheel, validated with twine
```

To run one test module or a single test:

```bash
python -m unittest tests.unit.deep_find_in_lists_test
python -m unittest tests.unit.deep_find_in_lists_test.TestFindInLists.test_all_values_of_list
```

## Changelog

Release notes live in
[CHANGELOG.md](https://github.com/otsobide/deepfinder.py/blob/main/CHANGELOG.md).

## Contributing

Contributions are welcome. Please keep the suite green and the coverage at 100%,
and add a test that fails before your fix and passes after it.
[CONTRIBUTING.md](https://github.com/otsobide/deepfinder.py/blob/main/CONTRIBUTING.md)
has the ground rules and the release procedure.

## License

This project is licensed under the MIT License - see the [LICENSE](https://github.com/otsobide/deepfinder.py/blob/main/LICENSE) file for details.
