Skip to content

Store Backends

This article works through every backend CLABE ships, and how to combine them with CompositeStore. See Stores for the shared Kind / Store vocabulary these examples build on.

Backend Serves Interactivity Notes
LocalFileStore any kind Pick list over matching files The config library — a directory of JSON files, e.g. on a shared network drive
MemoryStore any kind Pick list over in-process records Tests, and assembling records without touching disk
DataverseStore trainer_state only Pick list over recent suggestions Needs the aind-services extra
ConfiergeStore one ficus namespace, whatever the kind Never prompts — ficus merges the layers Needs the aind-services extra
CompositeStore whatever its routes serve Delegates to the routed backend Routes a kind name to a backend — the payoff over subclassing

LocalFileStore

A store over a directory of JSON files. Reads glob for matching files; writes overwrite and create any missing parent directories.

from clabe.stores import Kind, LocalFileStore

RIG = Kind.from_rig(MyRigModel)
TASK = Kind.from_task(MyTaskModel)

store = LocalFileStore(root=r"\\allen\aind\scratch\AindBehavior.db\MyProject")

rig = store.resolve(RIG)
store.write(TASK, my_task_logic)

computer_name is filled into the store's scope automatically — from COMPUTERNAME, falling back to platform.node() — since resolving a rig config needs to know which machine is asking:

store.scope  # {"computer_name": "RIG-01"}

The on-disk layout

The default Layout — DefaultLayout — reproduces the config-library tree used today:

<root>/
  Rig/<computer_name>/*.json
  Task/*.json
  Subjects/<subject>/task.json
  Subjects/<subject>/trainer_state.json

task reads the subject's folder first and falls back to the shared library — an ordered scope chain, rather than the nested try/while a picker used to do inline:

store = store.scoped(subject="789012")
task_logic = store.resolve(TASK)  # Subjects/789012/task.json, else Task/*.json

Kinds other than rig and task are per-subject state living beside the subject's task; with no subject in scope they sit at the library root instead — which is what makes a one-off store pointed at a single directory work (see Recovering a past session below).

A custom layout

Pass your own Layout when the on-disk shape doesn't match DefaultLayout — implementing read (glob patterns, highest priority first) and write (the single path to persist to):

from collections.abc import Sequence
from clabe.stores import LocalFileStore, Scope


class FlatLayout:
    """Every kind lives directly at the library root, no subject nesting."""

    def read(self, kind_name: str, scope: Scope) -> Sequence[str]:
        return [f"{kind_name}.json"]

    def write(self, kind_name: str, scope: Scope) -> str:
        return f"{kind_name}.json"


store = LocalFileStore(root="./my_lib", layout=FlatLayout())

MemoryStore

Holds records in a process-local list. Nothing touches disk or a network, so it's the right fake for tests and for assembling records in code.

from clabe.stores import Kind, MemoryStore

MANIPULATOR = Kind(ManipulatorPosition)

store = MemoryStore()
store.write(MANIPULATOR, ManipulatorPosition(x=1, y=2, z=3))

assert store.list(MANIPULATOR) == [ManipulatorPosition(x=1, y=2, z=3)]

Like DataverseStore, writes append rather than overwrite, so several records of one kind accumulate as candidates — useful for exercising resolve's many-candidates prompt in a test without a real backend behind it. A record written under a scope is only visible to a read whose scope agrees with every one of those narrowings; a record written unscoped is visible everywhere:

store.write(MANIPULATOR, ManipulatorPosition(x=0, y=0, z=0), scope={"subject": "123"})

store.list(MANIPULATOR)  # [] — no subject in scope
store.scoped(subject="123").list(MANIPULATOR)  # [ManipulatorPosition(x=0, y=0, z=0)]

DataverseStore

A store over the Dataverse suggestion tables — the trainer-state history kept in aibs_fact_mouse_proposed_behavior_sessionses, keyed through aibs_dim_mices. It serves trainer_state only; resolving or writing any other kind raises LookupError.

clabe.stores.dataverse isn't imported by clabe.stores itself, since it needs requests, msal and pyyaml — install the extra and import it explicitly:

pip install "aind-clabe[aind-services]"
from clabe.stores.dataverse import DataverseStore
from clabe.stores import Kind

SUGGESTION = Kind.from_trainer_state()

store = DataverseStore(history=5)  # offer the 5 most recent suggestions
store = store.scoped(subject="789012", task_name="MyTask")

trainer_state = store.resolve(SUGGESTION)
store.write(SUGGESTION, next_trainer_state)

resolve and list both require subject and task_name in scope — set them once with scoped, as above, rather than passing scope= on every call. At the default history=1 the latest suggestion is used without prompting; raise it to offer a short pick list of recent history instead.

Credentials are a ServiceSettings subclass, so they resolve from the dataverse section of your known config files (or the environment) by default:

dataverse:
  tenant_id: "..."
  client_id: "..."
  org: "your-org"

username/password are not meant for the YAML file — DataverseStore() with no client builds one from a KeePass entry (svc_sipe by default). Pass your own client=_DataverseRestClient(...) to bypass KeePass entirely, e.g. in tests.

ConfiergeStore

Ficus is a layered configuration service: documents live under a namespace and are overridden per scope — a computer, a subject — with the effective config merged server-side. ConfiergeStore is a thin adapter between the store API and ficus' confierge client. It translates scopes, and leaves merging, scope validation and the offline cache to ficus and confierge.

It needs the aind-services extra, so it is imported explicitly:

from clabe.stores import Kind
from clabe.stores.confierge import ConfiergeSettings, ConfiergeStore

RIG = Kind.from_rig(AindVrForagingRig)

store = ConfiergeStore(ConfiergeSettings(namespace="aind-behavior-vr-foraging"))

rig = store.resolve(RIG)
rig = store.scoped(subject="789907").resolve(RIG)  # adds the subject's overrides
store.scoped(subject="789907").write(RIG, rig)

A read yields exactly one document, so it never prompts. If ficus is unreachable, confierge serves its last cached copy of the same request.

Settings are a ServiceSettings subclass and resolve from the confierge section of your known config files:

confierge:
  namespace: aind-behavior-vr-foraging
  base_url: http://eng-tools/ficus-dev  # optional; else $FICUS_BASE_URL, else confierge's default
  mode: null                            # optional ficus mode

overwrite_defaults, create_if_missing and append_new_fields_to_last_scope are passed through to ficus on every write.

Scope mapping

The store's scope keys are not ficus' scope names. [DEFAULT_SCOPE_MAPPING][clabe.stores.confierge.DEFAULT_SCOPE_MAPPING] translates one into the other:

Store scope key Ficus scope
computer_name hostname
subject subject_id

computer_name is filled in with this machine's name, as LocalFileStore does. It is the machine name, not the AIND rig name (aibs_comp_id, e.g. FRG.4A): ficus is keyed on the former. Scope keys outside the mapping, such as task_name, are not sent, so one scope can be shared across several stores.

To address a scope ficus gains later, extend the mapping:

from clabe.stores.confierge import DEFAULT_SCOPE_MAPPING

store = ConfiergeStore(settings, scope_mapping={**DEFAULT_SCOPE_MAPPING, "rig": "rig_id"})

One namespace per store

A store addresses a single namespace whatever the kind, so resolving two kinds from one store returns the same document. To serve several kinds, give each its own namespace and route them with CompositeStore:

store = CompositeStore(
    routes={
        "rig": ConfiergeStore(ConfiergeSettings(namespace="vr-foraging-rig")),
        "task": ConfiergeStore(ConfiergeSettings(namespace="vr-foraging-task")),
    },
)

A record must serialize to a JSON object to be written.

CompositeStore: routing by kind

The payoff composition buys over the old picker inheritance: a deployment keeping rigs and tasks on a network share but trainer state in Dataverse is a routing table, not a subclass.

from clabe.stores import CompositeStore, Kind, LocalFileStore
from clabe.stores.dataverse import DataverseStore

RIG = Kind.from_rig(MyRigModel)
TASK = Kind.from_task(MyTaskModel)
SUGGESTION = Kind.from_trainer_state()

store = CompositeStore(
    default=LocalFileStore(root=r"\\allen\aind\scratch\AindBehavior.db\MyProject"),
    routes={"trainer_state": DataverseStore()},
    # a ficus-backed rig route (routes={"rig": ConfiergeStore(...)}) is the same shape
)

rig = store.resolve(RIG)  # → LocalFileStore (the default)
trainer_state = store.resolve(SUGGESTION)  # → DataverseStore (routed by name)

Every method — resolve, list, write, scoped — delegates to whichever backend serves the kind, so each backend's own presentation is used (a flat file pick list for RIG, a queried table of recent suggestions for SUGGESTION). scoped narrows every backend at once:

store = store.scoped(subject=session.subject)  # narrows both the local store and Dataverse

A kind with no route and no default raises LookupError naming the kind — which kinds a deployment actually serves is a fact assembled from configuration, not something the type system tracks.

Recipes

Scoping a store to the animal

from clabe.session import SessionBuilder

session = SessionBuilder(launcher).build()
store = store.scoped(subject=session.subject)

Per-animal reconfiguration with ByAnimalModifier

ByAnimalModifier reads and writes per-animal state through a store, so the same modifier works against any backend:

from clabe.modifiers import ByAnimalModifier
from clabe.stores import Kind

MANIPULATOR = Kind(ManipulatorPosition)


class ManipulatorModifier(ByAnimalModifier[MyRig]):
    def __init__(self, subject, store):
        super().__init__(subject, store, MANIPULATOR, "manipulator.position")

    def _process_before_update(self):
        return read_position_from_hardware()


modifier = ManipulatorModifier(session.subject, store)
rig = modifier.inject(rig)  # leaves the rig untouched if nothing is stored
...
modifier.update()

The subject is a constructor argument, and the modifier narrows the store with it. A store with no subject in scope reads and writes wherever the backend places a subject-less record, which for ConfiergeStore is the computer's own scope.

Recovering a past session

Session is serializable — a receipt of an acquisition — so Kind.from_session() exists, and a one-off LocalFileStore pointed straight at a session directory can load one back with no subject nesting involved:

from clabe.stores import Kind, LocalFileStore

session = LocalFileStore(root=session_directory).resolve(Kind.from_session())
store = store.scoped(subject=session.subject)

Testing against a store

MemoryStore needs no frontend for non-interactive calls, which makes it the right fake for exercising list/write-based code (like a modifier) without mocking a UI:

from clabe.stores import Kind, MemoryStore

MANIPULATOR = Kind(ManipulatorPosition)


def test_inject_uses_the_stored_record():
    store = MemoryStore().scoped(subject="123")
    store.write(MANIPULATOR, ManipulatorPosition(x=1, y=2, z=3))

    rig = ManipulatorModifier(store).inject(MyRig())

    assert rig.manipulator.position == ManipulatorPosition(x=1, y=2, z=3)

Exercising resolve in a test does need a frontend — register a fake with ui.use_frontend for the duration:

from clabe import ui

with ui.use_frontend(fake_frontend):
    rig = store.resolve(RIG)