Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/explanations/decisions/0021-attr-backend-and-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# 21. AttrBackend and AttrFactory

Date: 2026-09-10

**Related:** [ADR 9](0009-handler-to-attribute-io-pattern.md),
[ADR 13](0013-declarative-procedural-split-and-controller-filler.md),
[ADR 14](0014-attribute-io-rw-rework.md)

## Status

Accepted

## Context

`SCPIController` ([ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s
worked example of a filler-based protocol layer) hand-built a getter/setter closure
per declared attribute, each one closing over the attribute's command token and,
for a getter, the datatype that parses the device's text answer:

```python
self.filler.fill_attribute(
declaration.name,
getter=Polled(self._getter(param.param, datatype), period=self.poll_period),
setter=setter,
**param.meta,
)
```

Every controller that binds many attributes to one wire protocol re-implements this
same shape - a closure factory, `Polled` wrapping, and an `isinstance` check to skip
building a setter for a read-only attribute - with nothing shared between them. There
was no protocol-agnostic concept of "a thing that knows how to get/set a value given
some protocol-specific arguments" for `ControllerFiller` to build on.

## Decision

Introduce `AttrBackend` (`fastcs/attributes/backend.py`), a `Protocol` describing
exactly that:

```python
class AttrBackend(Protocol[*Ts, DType_T]):
async def get(self, *args: *Ts) -> DType_T: ...
async def set(self, value: DType_T, *args: *Ts) -> None: ...
```

and `AttrFactory` (`fastcs/attributes/factory.py`), built from one, with two halves:

- **Construction** - `attr_r`/`attr_w`/`attr_rw` build a *new* attribute with the
backend's IO bound, for a controller that discovers attributes at runtime (no
`Declaration` to fill).
- **Filling** - `fill(attr, *args, polled=...)` binds IO onto an attribute a
`ControllerFiller` already created from a class-body hint, dispatching on the
attribute's actual kind (`AttrR`/`AttrW`/`AttrRW`) so callers don't branch
themselves.

`ControllerFiller.fill_from_backend(declaration, backend, *args, polled=..., **meta)`
is the entry point a protocol layer like `SCPIController` actually calls: it looks up
`declaration.child`, delegates IO-binding to `AttrFactory.fill`, and applies `**meta`
through the existing `fill_attribute` (unchanged - `fill_attribute` already treats a
missing `getter`/`setter` as "nothing to bind here", so no new code path was needed
in it for the meta-only call `fill_from_backend` makes).

**This is not `AttributeIO`/`AttributeIORef` (ADR 9) come back.** ADR 14 deleted that
pattern because its `AttributeIORef` existed solely to carry inert per-attribute data
until a class-scope `Attribute` instance's `AttributeIO` could be found *by type* at
`post_initialise()` - a problem [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)
had already made obsolete by moving every attribute's construction into `__init__`/
`initialise()`, where a live connection is already in scope. `AttrBackend` has no ref
object, no type-based dispatch registry, and no controller-wide "list of IOs to
connect": a controller constructs its backend and hands it directly to
`AttrFactory`/`fill_from_backend` itself, the same way it would hand a bare getter/
setter to `fill_attribute` today. `SCPIController` now builds one `SCPIBackend` per
instance (mnemonic + parser as call arguments, not per-attribute construction state)
instead of one closure pair per attribute.


## Consequences

- `SCPIController` migrates from two hand-built closure factories (`_getter`/
`_setter`) to one `SCPIBackend` instance and a single `fill_from_backend` call;
behaviour is unchanged (every existing SCPI demo test passes unmodified).
- A future protocol layer (REST, say) gets the same shared mechanism for free:
implement `get`/`set`, hand the backend to `fill_from_backend`.
- `fill_attribute`'s existing raw-getter/setter path is untouched and still the right
choice for a controller that does not want a shared backend abstraction.
2 changes: 2 additions & 0 deletions src/fastcs/attributes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@
from .attr_w import UnboundSetter as UnboundSetter
from .attribute import Attribute as Attribute
from .attribute import AttributeAccessMode as AttributeAccessMode
from .backend import AttrBackend as AttrBackend
from .factory import AttrFactory as AttrFactory
from .severity import Severity as Severity
from .update import Update as Update
32 changes: 32 additions & 0 deletions src/fastcs/attributes/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""`AttrBackend` - a protocol-agnostic get/set for one attribute's value."""

from __future__ import annotations

from typing import Protocol, TypeVarTuple

from fastcs.attributes.update import Update
from fastcs.datatypes import DType_T

Ts = TypeVarTuple("Ts")
"""The positional arguments a backend's `get`/`set` identify a resource by"""


class AttrBackend(Protocol[*Ts, DType_T]):
"""Something that can `get`/`set` a value given protocol-specific arguments.

One instance typically serves every attribute a controller declares. `args` is how
a single call says *which* attribute it means.
"""

async def get(self, *args: *Ts) -> DType_T:
"""Read the value identified by `args`."""
...

async def set(self, value: DType_T, *args: *Ts) -> DType_T | Update[DType_T] | None:
"""Write `value` to the resource identified by `args`.

Mirrors `Setter[DType_T]` (ADR 0014): `None` is fire-and-forget, and a
returned value or `Update` is the device's accepted/clamped value, applied
to the attribute's readback and setpoint immediately.
"""
...
102 changes: 102 additions & 0 deletions src/fastcs/attributes/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""`AttrFactory` - binds an `AttrBackend`'s IO onto attributes."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Generic, cast

from fastcs.attributes.attr_r import AttrR, Getter, NotPolled, Polled, Schedule
from fastcs.attributes.attr_rw import AttrRW
from fastcs.attributes.attr_w import AttrW, Setter
from fastcs.attributes.attribute import Attribute
from fastcs.attributes.backend import AttrBackend, Ts
from fastcs.attributes.update import Update
from fastcs.datatypes import DType_T


@dataclass
class AttrFactory(Generic[*Ts, DType_T]):
"""Binds a backend's IO onto attributes - new ones, or ones already declared."""

backend: AttrBackend[*Ts, DType_T]

def attr_r(
self,
datatype: type[DType_T],
*args: *Ts,
schedule: Schedule[DType_T] | None = None,
) -> AttrR[DType_T]:
"""Build a new read-only attribute, its getter bound to this backend."""
getter = self._schedule(*args, schedule=schedule)
attribute = AttrR(cast(Any, datatype), getter=getter)
return cast(AttrR[DType_T], attribute)

def attr_w(self, datatype: type[DType_T], *args: *Ts) -> AttrW[DType_T]:
"""Build a new write-only attribute, its setter bound to this backend."""
attribute = AttrW(cast(Any, datatype), setter=self._setter(*args))
return cast(AttrW[DType_T], attribute)

def attr_rw(
self,
datatype: type[DType_T],
*args: *Ts,
schedule: Schedule[DType_T] | None = None,
) -> AttrRW[DType_T]:
"""Build a new read-write attribute, its getter/setter bound to this backend."""
attribute = AttrRW(
cast(Any, datatype),
getter=self._schedule(*args, schedule=schedule),
setter=self._setter(*args),
)
return cast(AttrRW[DType_T], attribute)

def fill(
self,
attr: Attribute[DType_T],
*args: *Ts,
schedule: Schedule[DType_T] | None = None,
) -> None:
"""Bind this backend's get/set onto an already-constructed attribute.

Args:
attr: The already-constructed attribute to bind IO onto
args: Forwarded to the backend's `get`/`set` to identify the resource
schedule: A bare `Polled(period=...)`/`NotPolled()` to read the getter
on, or `None` to read once, at connect

"""
if isinstance(attr, AttrR):
attr.set_getter(self._schedule(*args, schedule=schedule))
if isinstance(attr, AttrW):
attr.set_setter(self._setter(*args))

def _schedule(
self, *args: *Ts, schedule: Schedule[DType_T] | None
) -> Getter[DType_T] | Schedule[DType_T]:
"""This backend's getter, with `schedule` applied.

`Polled`/`NotPolled` bind a getter onto themselves when called (the
same mechanism `AttrR.declare`'s own `schedule` argument uses), so
there is no `Polled`/`NotPolled` branch to write here.
"""
getter = self._getter(*args)

if isinstance(schedule, Polled | NotPolled) and schedule.getter is not None:
raise TypeError(
"The schedule given to `AttrFactory` already has a getter; pass "
"a bare Polled(period=...) or NotPolled()"
)

return getter if schedule is None else schedule(getter)

def _getter(self, *args: *Ts) -> Getter[DType_T]:
async def get() -> DType_T:
return await self.backend.get(*args)

return get

def _setter(self, *args: *Ts) -> Setter[DType_T]:
async def set_(value: DType_T) -> DType_T | Update[DType_T] | None:
return await self.backend.set(value, *args)

return set_
68 changes: 53 additions & 15 deletions src/fastcs/controllers/filler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ class OdinDetector(Controller):
frames: AttrRW[int] # exists as soon as __init__ returns

async def initialise(self) -> None:
self.filler.fill_attribute("frames", getter=..., setter=...)
self.filler.fill_attribute(
self.filler.declarations["frames"], getter=..., setter=...
)

so ``self.frames`` can be referenced by the rest of ``__init__`` - the rule
ADR 0013 takes from ophyd-async, and what makes ``initialise`` safe to run in
Expand Down Expand Up @@ -47,6 +49,8 @@ async def initialise(self) -> None:
from fastcs.attributes import Attribute, AttrR, AttrW
from fastcs.attributes.attr_r import Getter, Schedule
from fastcs.attributes.attr_w import Setter
from fastcs.attributes.backend import AttrBackend, Ts
from fastcs.attributes.factory import AttrFactory
from fastcs.datatypes import DType, DType_T, Meta, validate_meta
from fastcs.methods import Method

Expand Down Expand Up @@ -231,7 +235,7 @@ def __iter__(self) -> Iterator[tuple[Attribute | None, tuple[Any, ...]]]:

def fill_attribute(
self,
name: str,
declaration: Declaration,
getter: Getter[DType_T] | Schedule[DType_T] | None = None,
setter: Setter[DType_T] | None = None,
datatype: type[DType_T] | None = None,
Expand All @@ -240,7 +244,11 @@ def fill_attribute(
"""Provision a declared attribute with its IO and metadata.

Args:
name: The name the class body declared
declaration: The class-body declaration to fill, as yielded by
`declarations` or iteration over this filler. Look one up by
name with ``self.filler.declarations[name]`` if you don't
already have it - iterating declarations to find the one you
want is the common case this signature is for.
getter: IO to read the value with, optionally wrapped in a
`Polled`/`NotPolled` schedule
setter: IO to write the value with
Expand All @@ -255,31 +263,25 @@ def fill_attribute(
The attribute, which is the same object the hint created

Raises:
KeyError: If nothing of that name was declared
KeyError: If the declaration names a promise rather than a built
attribute
TypeError: If the attribute has no half the given IO would fill,
the datatype disagrees with the hint, or the metadata does not
suit the datatype

"""
declaration = self._declarations.get(name)
if declaration is None:
raise KeyError(
f"{type(self._controller).__name__} has no attribute declaration "
f"named '{name}' to fill. Declare it as a class-body hint with its "
"datatype, or add the attribute with `add_attribute`."
)

if declaration.child is None:
# A hint that does not name its datatype - `state: AttrR` - is a
# promise rather than something the filler could build, so there is
# no attribute here to provision.
raise KeyError(
f"{type(self._controller).__name__} declared '{name}' as "
f"{type(self._controller).__name__} declared '{declaration.name}' as "
f"{declaration.hint.type_} without a datatype, so there is no "
"attribute to fill. Subscript the hint with the datatype it holds, "
"or add the attribute with `add_attribute`."
)

name = declaration.name
attribute = declaration.child

# The whole request is checked before any of it is applied, so that a
Expand Down Expand Up @@ -333,13 +335,49 @@ def fill_attribute(

return attribute

def fill_meta(self, name: str, meta: Meta) -> Attribute:
def fill_from_backend(
self,
declaration: Declaration,
backend: AttrBackend[*Ts, DType_T],
*args: *Ts,
schedule: Schedule[DType_T] | None = None,
**meta: Unpack[Meta],
) -> Attribute:
"""Fill a declared attribute's IO from a backend, then apply its metadata.

Args:
declaration: The class-body declaration to fill
args: Forwarded to the backend's `get`/`set` to identify the resource
schedule: A bare `Polled(period=...)`/`NotPolled()` to read the getter
on, or `None` to read once, at connect - see `AttrFactory.fill`
meta: Metadata for the attribute, validated against the datatype the
hint declared

Returns:
The attribute, which is the same object the hint created

Raises:
KeyError: If the declaration names a promise rather than a built
attribute - see `fill_attribute`

"""
# Meta first, IO second: `fill_attribute` is what raises for a promise
# or bad metadata, and it should do so before any IO is bound, not after
# - otherwise a rejected fill would leave the attribute's getter/setter
# already set from the backend, refusing a corrected retry.
attribute = self.fill_attribute(declaration, **meta)

AttrFactory(backend).fill(attribute, *args, schedule=schedule)

return attribute

def fill_meta(self, declaration: Declaration, meta: Meta) -> Attribute:
"""Fill a declared attribute's metadata from an extras object.

The shape a protocol layer wants: ``SCPIParam(...).meta`` in one go,
validated against the datatype the hint declared.
"""
return self.fill_attribute(name, **meta)
return self.fill_attribute(declaration, **meta)

def check_filled(self) -> None:
"""Raise if anything the class body promised does not exist.
Expand Down
2 changes: 1 addition & 1 deletion src/fastcs/demo/eiger.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ async def initialise(self) -> None:
# access mode and datatype the hint promised against what
# the device turned out to report.
self.filler.fill_attribute(
param, datatype=datatype, getter=getter, setter=setter
declaration, datatype=datatype, getter=getter, setter=setter
)
elif setter is None:
self.add_attribute(param, AttrR(datatype, getter=getter))
Expand Down
Loading
Loading