Skip to main content

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.

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:

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:

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

Compare with the field-wrapper style used elsewhere:

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

  • 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.

pip install storebind

or with uv:

uv add storebind

Quick Start

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

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:

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:

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

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:

@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.

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

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

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:

registry = StoreRegistry(validate_types=False)

Snapshots

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

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:

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:

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 for dependency management:

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

Run the checks:

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 © Benjamin Chau

Metadata

Release files for storebind 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for storebind 0.1.1
File Size Uploaded Provenance Actions
storebind-0.1.1.tar.gz 12.2 kB Without provenance Details for storebind-0.1.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for storebind 0.1.1
File Interpreter ABI Platform Actions
storebind-0.1.1-py3-none-any.whl Python 3 none any Details for storebind-0.1.1-py3-none-any.whl

Total release size: 25.6 kB

Release files / storebind-0.1.1.tar.gz

Download URL storebind-0.1.1.tar.gz
Size 12.2 kB
Tags Source
SHA-256 checksum
How to use checksums
06157db818100ae5789c8c76754defba081700137ce205a10985b8ebe1b3b9da
BLAKE2b-256 checksum
How to use checksums
3b7fb635449a57748cdea740e101502a3eddbb0e2fcf3cda8bafaa7a55fb1488
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.25 {"installer":{"name":"uv","version":"0.9.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / storebind-0.1.1-py3-none-any.whl

Download URL storebind-0.1.1-py3-none-any.whl
Size 13.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
adfc17d89e8e18355fa8c0549065e2c435ae7d0195d1047cbbdd91cbc6468e50
BLAKE2b-256 checksum
How to use checksums
aff9c1ed1dde896b3180de746bba93ac6808f8978895828acd4339ef8b2c4e9b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.25 {"installer":{"name":"uv","version":"0.9.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for storebind 0.1.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page