Metadata-Version: 2.3
Name: storebind
Version: 0.1.1
Summary: Typed application state, dependency injection, and reactive subscriptions for Python.
Keywords: state-management,dependency-injection,reactive,store,typed-state
Author: Benjamin Chau
Author-email: Benjamin Chau <68836494+swarfte@users.noreply.github.com>
License: MIT License
         
         Copyright (c) 2026 Benjamin Chau
         
         Permission is hereby granted, free of charge, to any person obtaining a copy
         of this software and associated documentation files (the "Software"), to deal
         in the Software without restriction, including without limitation the rights
         to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
         copies of the Software, and to permit persons to whom the Software is
         furnished to do so, subject to the following conditions:
         
         The above copyright notice and this permission notice shall be included in all
         copies or substantial portions of the Software.
         
         THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
         IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
         FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
         AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
         LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
         OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
         SOFTWARE.
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
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.11
Description-Content-Type: text/markdown

# StoreBind

**A typed application-state registry that binds store values to function inputs and outputs.**

StoreBind is a lightweight, zero-dependency state library for Python. You declare stores
with plain type annotations, read and write their fields through a global registry — and,
the part that makes StoreBind StoreBind, you connect store fields to function boundaries
declaratively: `@store.inject` feeds state into function parameters, `@store.capture`
writes function results back into state, and `store.subscribe` notifies you on every
change.

```python
from storebind import BaseStore, store

class ConfigStore(BaseStore):
    theme: str = "dark"

store.register(ConfigStore)

# Store field -> function parameter
@store.inject(ConfigStore, "theme", as_="theme")
def render(theme: str) -> str:
    return f"<html theme={theme}>"

render()                              # "<html theme=dark>"

# Function return value -> Store field
@store.capture(ConfigStore, "theme")
def detect_theme() -> str:
    return "light"

detect_theme()
store.get(ConfigStore, "theme")       # "light"
```

## Why StoreBind?

StoreBind is **not** a dependency injection container and **not** a signal library. It is a
typed application-state registry that declaratively connects store fields to function
boundaries:

- `store.get()` and `store.set()` for direct state access
- `@store.inject()` for store-to-parameter binding
- `@store.capture()` for return-value-to-store binding
- `store.subscribe()` for state change notifications

The two decorators form one clear data flow:

```text
Store field
   │  @store.inject
   ▼
Function parameter
   │  execution
   ▼
Function return value
   │  @store.capture
   ▼
Store field
```

Existing Python libraries usually pick one side of this problem: observable values and
computed graphs, object-attribute validation, or service wiring. StoreBind focuses on the
**function boundary itself** — state flows into your functions and results flow back out,
as declarative bindings rather than manual `get()`/assignment plumbing.

### State as plain type annotations

A StoreBind store is just a class with annotated fields:

```python
class ConfigStore(BaseStore):
    theme: str = "dark"
    style: str = "default"
```

Compare with the field-wrapper style used elsewhere:

```python
theme = observable("dark")            # signal libraries
theme = param.String(default="dark")  # Param
theme = Unicode("dark")               # Traitlets
```

No wrappers means your stores stay ordinary Python: IDE autocomplete, type checkers,
inheritance, and `isinstance` all behave exactly as you expect — while StoreBind layers
runtime validation, deep-copied defaults, and subscriptions on top.

### What StoreBind is not

- **Not a signal engine** — there is no computed graph or automatic dependency tracking
  (both are on the [roadmap](#roadmap)).
- **Not a DI container** — no providers, scopes, or lifecycles. The only thing injected is
  a typed state value.
- **Not "Pinia for Python"** — no devtools, persistence, plugins, or framework
  integration. Just typed state and its bindings to function boundaries.

## Table of Contents

- [Features](#features)
- [Why StoreBind?](#why-storebind)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Core Concepts](#core-concepts)
  - [Defining a Store](#defining-a-store)
  - [Registering Stores](#registering-stores)
  - [Reading and Writing State](#reading-and-writing-state)
  - [Injecting State into Functions](#injecting-state-into-functions)
  - [Capturing Results into State](#capturing-results-into-state)
  - [Subscriptions](#subscriptions)
  - [Runtime Type Validation](#runtime-type-validation)
  - [Snapshots](#snapshots)
  - [Thread Safety](#thread-safety)
- [How StoreBind Compares](#how-storebind-compares)
- [Error Handling](#error-handling)
- [API Overview](#api-overview)
- [Roadmap](#roadmap)
- [Development](#development)
- [License](#license)

## Features

- **Function-boundary binding** — `@store.inject` binds store fields to function
  parameters; `@store.capture` binds return values back to store fields. Sync and async
  functions both work, and explicit caller arguments always win over injection.
- **Plain annotated stores** — state is declared as ordinary type-annotated class
  attributes. No field descriptors, no magic — IDE autocomplete and static type checking
  work out of the box.
- **Application-wide registry** — register a store class once, access the shared instance
  anywhere via `store.use(...)`, or create isolated registries for tests and libraries.
- **Runtime type validation** — declared types are enforced on registration and on every
  write, including generics like `list[T]`, `dict[K, V]`, and unions.
- **Reactive subscriptions** — get notified on every state change with `(new_value,
  old_value)`; unsubscribe with a single call.
- **Thread-safe** — all registry operations are guarded by a re-entrant lock.
- **Zero dependencies** — pure Python 3.11+, fully type-annotated (`py.typed`), passes
  `mypy --strict`.

## Installation

Requires Python 3.11 or newer.

```bash
pip install storebind
```

or with [uv](https://docs.astral.sh/uv/):

```bash
uv add storebind
```

## Quick Start

```python
from storebind import BaseStore, store


# 1. Define a store: annotated fields, optional defaults.
class SessionStore(BaseStore):
    user: str                      # required — must be provided at registration
    logged_in: bool = False        # optional — defaults to False


# 2. Register it (the module-level `store` registry is ready to use).
store.register(SessionStore, user="alice")


# 3. Read and write state.
assert store.get(SessionStore, "logged_in") is False
store.set(SessionStore, "logged_in", True)


# 4. React to changes.
unsubscribe = store.subscribe(
    SessionStore,
    "logged_in",
    lambda new, old: print(f"logged_in: {old} -> {new}"),
)

store.set(SessionStore, "logged_in", False)   # prints: logged_in: True -> False
unsubscribe()


# 5. Bind state to function boundaries — both directions at once.
@store.inject(SessionStore, "user", as_="user")
@store.capture(SessionStore, "logged_in")
def sign_in(user: str) -> bool:
    print(f"Signing in {user}...")
    return True

sign_in()                                  # prints: Signing in alice...
store.get(SessionStore, "logged_in")       # True
```

Use `StoreRegistry()` directly instead of the shared `store` singleton when you need
isolated state (tests, multi-tenant apps, libraries):

```python
from storebind import BaseStore, StoreRegistry

registry = StoreRegistry()
registry.register(SessionStore, user="bob")
```

## Core Concepts

### Defining a Store

Subclass `BaseStore` and declare state as type-annotated class attributes:

```python
from storebind import BaseStore

class ConfigStore(BaseStore):
    theme: str = "dark"
    font_size: int = 14
    plugins: list[str] = []         # safe: initial values are deep-copied per instance
    api_key: str                    # no default → must be supplied at registration
```

Rules:

- Every annotated field **must** have a default or be provided at registration, otherwise
  `MissingStateValueError` is raised.
- Passing a keyword argument that is **not** a declared field raises `TypeError`.
- Field values (defaults and initial values alike) are **deep-copied** into the instance, so
  mutable defaults are safe.
- Inheritance works: `field_types()` collects annotations across the whole MRO.
- Fields annotated as `ClassVar` or with underscore-prefixed names are ignored.

### Registering Stores

A store must be registered before it can be used through a registry:

```python
registry.register(ConfigStore, api_key="secret")   # instantiate + validate + store
registry.register(ConfigStore, replace=True)       # swap the instance, drop its subscribers
registry.is_registered(ConfigStore)                # True
registry.use(ConfigStore)                          # the shared ConfigStore instance
registry.unregister(ConfigStore)                   # remove it (and its subscribers)
```

- Registering the same class twice raises `StoreAlreadyRegisteredError` unless
  `replace=True`.
- Registering anything that is not a `BaseStore` subclass raises `TypeError`.
- `register()` returns the store instance, so you can keep a typed reference:
  `config = registry.register(ConfigStore, api_key="secret")`.

### Reading and Writing State

```python
registry.get(ConfigStore, "theme")       # "dark"
registry.set(ConfigStore, "theme", "light")

theme = registry.use(ConfigStore).theme  # direct instance access also works
```

- Reading or writing a field the store never declared raises `StateNotDeclaredError`.
- `set()` validates the value's type, compares it to the current value, and **skips
  notification if nothing changed** (values compared with `==`).
- `set()` returns the new value.
- Subscribers are invoked **outside** the registry lock, so a callback may safely call back
  into the registry.

### Injecting State into Functions

`inject()` fills a function parameter with the current value of a store field — the
**store → function** half of the data flow. It works on sync and async functions, and an
explicit argument from the caller always takes priority:

```python
@registry.inject(ConfigStore, "theme", as_="theme")
def render(theme: str) -> str:
    return f"<html theme={theme}>"

render()                 # uses the store value, e.g. "light"
render(theme="dark")     # explicit argument wins

@registry.inject(SessionStore, "user", as_="username")
async def greet(username: str) -> str:
    return f"Hello, {username}!"

await greet()
```

The parameter name is given by `as_` and must exist on the function, otherwise a
`TypeError` is raised at decoration time. Positional callers are handled correctly: if the
parameter was already bound by position or keyword, no injection happens.

### Capturing Results into State

`capture()` is the mirror image of `inject()`: it writes a function's return value into a
store field — the **function → store** half of the data flow — and still returns it. Sync
and async functions are both supported.

```python
@registry.capture(ConfigStore, "font_size")
async def detect_font_size() -> int:
    return 18

await detect_font_size()
registry.get(ConfigStore, "font_size")   # 18
```

Stack the two decorators to close the loop — state in, result out, result back into state
(the captured field must be declared on the store, like any other):

```python
class ThemeStore(BaseStore):
    theme: str = "dark"
    resolved_theme: str = "default"

registry.register(ThemeStore)

@registry.inject(ThemeStore, "theme", as_="theme")
@registry.capture(ThemeStore, "resolved_theme")
def resolve_theme(theme: str) -> str:
    return {"dark": "midnight", "light": "daylight"}[theme]

resolve_theme()
registry.get(ThemeStore, "resolved_theme")   # "midnight"
```

Captured values pass through the same type validation as `set()`.

### Subscriptions

Subscribe to a single field of a single store. Callbacks receive
`(new_value, old_value)`:

```python
def on_theme_change(new_theme: str, old_theme: str) -> None:
    print(f"theme: {old_theme!r} -> {new_theme!r}")

unsubscribe = registry.subscribe(ConfigStore, "theme", on_theme_change)

registry.set(ConfigStore, "theme", "light")   # callback fires
unsubscribe()                                 # stop listening
registry.set(ConfigStore, "theme", "blue")    # no callback
```

Details:

- Passing `immediate=True` invokes the callback once right away with
  `(current_value, current_value)` — useful for initializing consumers.
- `set()` with an equal value does **not** notify.
- `unregister(store)` and `register(store, replace=True)` drop that store's subscribers
  automatically, since the instance they track is gone.
- `registry.reset()` clears **all** stores and subscribers — handy in test teardown.

### Runtime Type Validation

Declared annotations are enforced twice: when a store is registered, and on every `set()`
(and therefore on every captured return value). A mismatch raises `InvalidStateTypeError`
(a subclass of both `StoreBindError` and `TypeError`).

Supported checks:

| Annotation                  | Behavior                                        |
| --------------------------- | ----------------------------------------------- |
| `Any`                       | always passes                                   |
| `str`, `int`, custom classes| `isinstance` check                              |
| `A \| B` / `Optional[A]`    | passes if the value matches any member          |
| `list[T]`                   | checks the list **and** every item              |
| `dict[K, V]`                | checks keys and values                          |
| `tuple[...]`, `set[T]`      | checks the container type                       |
| anything else               | best effort — unverifiable constructs pass      |

Disable validation entirely (e.g. in performance-critical paths) with:

```python
registry = StoreRegistry(validate_types=False)
```

### Snapshots

Export the full state of any registered store as a plain dict:

```python
ConfigStore.to_dict()   # works on any instance, e.g. registry.use(ConfigStore).to_dict()
# {'theme': 'light', 'font_size': 14, ...}
```

`repr()` on a store instance shows all field values, which keeps test failures readable.

### Thread Safety

`StoreRegistry` guards its store map and subscriber sets with a `threading.RLock`. Writes
and subscription notifications are coordinated so subscribers are called after the state
change is committed and outside the lock — callbacks may read/write state or subscribe/
unsubscribe re-entrantly without deadlocking. Individual store instances themselves are not
synchronized: guard compound read-modify-write sequences at the application level if you
need them atomic.

## How StoreBind Compares

StoreBind is not the first library in this space, and it does not try to replace any of
these. Each has a different center of gravity:

| Library              | Center of gravity                                   | StoreBind's angle                                                        |
| -------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
| FynX                 | Reactive stores: observables and computed values    | Binds state at function boundaries (inject/capture) instead of observable graphs |
| reaktiv              | Signals with computed values and dependency tracking | No reactive graph to maintain — state propagates only where you injected or subscribed |
| Param                | Class-based typed parameters (e.g. `param.String`)  | Plain annotations instead of parameter descriptors, plus a registry and inject/capture |
| Traitlets            | Typed, observable attributes on classes             | Same plain-annotation schema, plus an application registry, inject, and capture |
| Dependency Injector  | Provider-based dependency injection for services    | Injects typed state values, not service objects — no containers or wiring modules |

If you need a full signal engine or a general DI container, use one of the above. What
StoreBind uniquely combines is:

```text
Typed Store schema
+ global registry
+ field-level get/set
+ decorator parameter injection
+ decorator return capture
+ subscription
```

The bidirectional function-boundary binding — `@store.inject` + `@store.capture` — is the
combination no other library offers as its core API.

## Error Handling

All library errors derive from `storebind.StoreBindError`, so you can catch them with one
except clause:

```python
from storebind import StoreBindError

try:
    store.set(ConfigStore, "nonexistent", 1)
except StoreBindError as error:
    print(error)
```

| Exception                     | Raised when                                            |
| ----------------------------- | ------------------------------------------------------ |
| `StoreBindError`              | base class for everything below                        |
| `StoreAlreadyRegisteredError` | a store class is registered twice without `replace`    |
| `StoreNotRegisteredError`     | an unregistered store is used                          |
| `StateNotDeclaredError`       | a field is read/written that the store never declared  |
| `MissingStateValueError`      | a required field gets no value at instantiation        |
| `InvalidStateTypeError`       | a value does not match its declared type (also a `TypeError`) |

## API Overview

`storebind` exports: `BaseStore`, `StoreRegistry`, `store` (the shared registry), and all
exceptions.

### `BaseStore`

| Member                    | Description                                              |
| ------------------------- | -------------------------------------------------------- |
| `__init__(**initial_values)` | builds the instance, deep-copying values; validates field names and required fields |
| `field_types()`           | classmethod → `dict[str, type]` of declared fields       |
| `to_dict()`               | snapshot of all field values                             |

### `StoreRegistry`

| Member                                                     | Description                                              |
| ---------------------------------------------------------- | -------------------------------------------------------- |
| `StoreRegistry(*, validate_types: bool = True)`            | create an isolated registry                              |
| `register(store_type, /, *, replace=False, **initial_values)` | instantiate, validate, and register; returns the instance |
| `unregister(store_type)`                                   | remove a store and its subscribers                       |
| `is_registered(store_type) -> bool`                        | membership test                                          |
| `use(store_type) -> StoreT`                                | the registered instance                                  |
| `get(store_type, attribute_name)`                          | read a field                                             |
| `set(store_type, attribute_name, value)`                   | validate, write, and notify; returns the value           |
| `inject(store_type, attribute_name, *, as_)`               | decorator: fill a parameter from state                   |
| `capture(store_type, attribute_name)`                      | decorator: write the return value into state             |
| `subscribe(store_type, attribute_name, callback, *, immediate=False) -> Callable[[], None]` | listen for changes; returns an unsubscribe function |
| `reset()`                                                  | drop every store and subscriber                          |

## Roadmap

The current API (Phase 1) is stabilizing. Planned for **Phase 2 — after API
stabilization**:

- [ ] `store.use(ConfigStore)` as the primary typed access path
- [ ] Store snapshots (immutable captures of full store state)
- [ ] Reset a single store (today only `reset()` clears everything)
- [ ] Wildcard store subscription (subscribe to all fields of a store)
- [ ] Computed state (derived values from other store fields)
- [ ] Dependency tracking (auto re-computation when inputs change)
- [ ] Nested state policy (rules for mutating nested structures)

## Development

This project uses [uv](https://docs.astral.sh/uv/) for dependency management:

```bash
git clone https://github.com/swarfte/storebind.git
cd storebind
uv sync
```

Run the checks:

```bash
uv run pytest src/tests      # test suite
uv run ruff check .          # lint
uv run mypy src              # strict type checking
```

The codebase is type-checked with `mypy --strict` and formatted/linted with `ruff`
(line length 88, rules `E`, `F`, `I`, `UP`, `B`, `SIM`).

## License

[MIT](LICENSE) © Benjamin Chau
