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()andstore.set()for direct state access@store.inject()for store-to-parameter binding@store.capture()for return-value-to-store bindingstore.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
- Why StoreBind?
- Installation
- Quick Start
- Core Concepts
- How StoreBind Compares
- Error Handling
- API Overview
- Roadmap
- Development
- License
Features
- Function-boundary binding —
@store.injectbinds store fields to function parameters;@store.capturebinds 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), passesmypy --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
MissingStateValueErroris 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
ClassVaror 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
StoreAlreadyRegisteredErrorunlessreplace=True. - Registering anything that is not a
BaseStoresubclass raisesTypeError. 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=Trueinvokes the callback once right away with(current_value, current_value)— useful for initializing consumers. set()with an equal value does not notify.unregister(store)andregister(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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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.