Metadata-Version: 2.4
Name: mirino
Version: 0.1.0
Summary: Record attribute and item access paths as reusable expression trees.
Author: Pierre Chat
Author-email: Pierre Chat <pierrechat@outlook.com>
License-Expression: MIT
License-File: LICENSE.txt
Requires-Python: >=3.13
Description-Content-Type: text/markdown

# Mirino

Mirino records attribute and item accesses instead of evaluating them.
Reading `person.address.city` builds an expression that remembers the path, and that path can be read, written, printed or checked later, against any object.

Mirino is used by the [Bolinette project](https://github.com/bolinette) to describe mapping profiles, to name the field an error came from, and to record the calls a mock expects.

```python
from dataclasses import dataclass
from mirino import ExpressionTree


@dataclass
class Address:
    city: str


@dataclass
class Person:
    address: Address


expr = ExpressionTree.new().address.city
person = Person(Address("Lyon"))

assert str(expr) == "$.address.city"
assert ExpressionTree.get_value(expr, person) == "Lyon"

ExpressionTree.set_value(expr, person, "Paris")
assert person.address.city == "Paris"
```

## Installation

```shell
$ pip install mirino  # or use your preferred package manager
```

## Requirements

Mirino requires Python 3.13 (or newer) and no other dependencies.

## Concepts

`ExpressionTree.new()` returns a `RootNode`, the start of every expression.
A root formats as `$`, or as the origin it was given, which is usually the type or the object the path will be read on.

Every attribute access on a node returns an `AttributeNode` and every subscript returns an `ElementNode`, and neither of them reads anything.
Both keep a reference to the node they came from, so an expression is a chain from its last access back to its root.

```python
from mirino import AttributeNode, ElementNode, ExpressionTree

assert str(ExpressionTree.new()) == "$"
assert str(ExpressionTree.new("Person")) == "Person"

expr = ExpressionTree.new().address["city"]
assert isinstance(expr, ElementNode)
assert isinstance(ExpressionTree.new().address, AttributeNode)
assert str(expr) == "$.address['city']"
```

A node intercepts every attribute access, so an expression has no methods of its own: `expr.get_value` would simply record one more step.
Everything is done from the outside through `ExpressionTree`, a namespace of static functions that cannot be instantiated.

## Reading and writing

`get_value` walks the recorded path on a real object and returns what it finds, `set_value` assigns the last step, and `get_attribute` returns the name or the key of that last step.
The root evaluates to the object itself, and cannot be assigned or named.

```python
from mirino import ExpressionTree

person = {"name": "Bob", "tags": ["a", "b"]}
root = ExpressionTree.new()

assert ExpressionTree.get_value(root, person) is person
assert ExpressionTree.get_value(root["name"], person) == "Bob"
assert ExpressionTree.get_value(root["tags"][1], person) == "b"
assert ExpressionTree.get_attribute(root["tags"]) == "tags"

ExpressionTree.set_value(root["name"], person, "Alice")
assert person["name"] == "Alice"
```

Attribute access goes through `getattr` and `setattr`, item access through `[]`, so an expression works on anything that supports them.
Assigning to a root raises an `ExpressionError`, and so does asking a root for its attribute name.

## Formatting

`format` renders the path, and is what `str()` and `repr()` use, which is what makes an expression readable in an error message.
`max_depth` keeps only the last steps, so a deep path can be shown relative to something else than its root.

```python
from mirino import ExpressionTree

expr = ExpressionTree.new().a.b.c

assert ExpressionTree.format(expr) == "$.a.b.c"
assert ExpressionTree.format(expr, max_depth=2) == "b.c"
assert ExpressionTree.format(expr, max_depth=1) == "c"
assert repr(expr) == "<AttributeNode: $.a.b.c>"
```

Item accesses count as one step too, and a string key is quoted while any other key is printed as it is.

## Validating an expression

An expression built by a caller, typically from a lambda, is not necessarily a plain chain of accesses.
`ensure_attribute_chain` walks it from the last step down to the root and raises an `AttributeChainError` when it finds a node that is not an attribute or an item access.
With `max_depth`, the chain must also be exactly that many steps long, or a `MaxDepthExpressionError` is raised.

```python
from mirino import ExpressionTree

ExpressionTree.ensure_attribute_chain(ExpressionTree.new().a["b"].c)
ExpressionTree.ensure_attribute_chain(ExpressionTree.new().a, max_depth=1)  # exactly one step
```

`ExpressionTree.new().a.b` with `max_depth=1` is too deep, and `ExpressionTree.new().a` with `max_depth=2` reaches its root too early.
Both raise, and the message holds the whole expression, not the step that failed.

## Reference

### Nodes

| Node            | Built by                     |
| --------------- | ---------------------------- |
| `RootNode`      | `ExpressionTree.new(origin)` |
| `AttributeNode` | `expr.attr`                  |
| `ElementNode`   | `expr[key]`                  |
| `ChildNode`     | Base of the last two         |

### Errors

All errors derive from `mirino.errors.MirinoError`, and carry the expression they were raised for.

| Error                     | Raised when                                                              |
| ------------------------- | ------------------------------------------------------------------------ |
| `ExpressionError`         | A node does not support the operation, such as assigning to a root       |
| `MaxDepthExpressionError` | An expression is deeper than the depth `ensure_attribute_chain` allows   |
| `AttributeChainError`     | An expression is not a plain chain of attribute and item accesses        |

## License

Mirino is released under the MIT license, see [LICENSE.txt](LICENSE.txt).
