controllers: ControllerFiller, and one declarative mechanism for attributes - #426
controllers: ControllerFiller, and one declarative mechanism for attributes#426coretl wants to merge 2 commits into
ControllerFiller, and one declarative mechanism for attributes#426Conversation
…ibutes ADR 0013's declarative/procedural split. A class body now holds declarations and decorated behaviour; an `Attribute` constructed there is rejected, because one object would be shared by every instance of the controller. `ControllerFiller` reads the class-body hints during `__init__`. A hint that names its datatype (`frames: AttrRW[int]`) becomes an unfilled attribute straight away, so it exists as soon as `__init__` returns and the rest of `__init__` may reference it - the rule that makes `initialise` safe to run in parallel. `fill_attribute` then provisions the IO and metadata in place, so a reference taken during construction is the object that ends up serving the device, and validates the metadata against the datatype the hint declared. A hint that cannot name its datatype - `state: AttrR`, an enum whose members only exist on the wire - is a promise instead: introspection must add it, and `check_filled(source)` reports what it did not. `HintedAttribute`, `_validate_type_hints` and the `_validate_hinted_*` family are gone; the filler subsumes them. So is the deepcopy half of `_bind_attrs`; `@attr`, `@command` and `@scan` binding is untouched. An `Annotated` hint's extras are handed back untouched through the filler's `(child, extras)` iteration, which is how a protocol layer outside core FastCS gets a declarative vocabulary of its own. Core defines none. The Eiger example now fills its declared parameters rather than adding a second attribute of the same name, and names the parameter tree as the source when a promise goes unkept. Closes #394 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PP7FfLHyvQYkzKs3nm8ARv
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…the p4p tests `_bind_attrs` skipped a class-body `Attribute` whose name also carried an annotation (`attr_1: AttrRW = AttrRW(...)`), on the reasoning that the hint was the declaration. But a bare `AttrRW` hint names no datatype, so the filler could not create it either: the attribute silently disappeared, which CI caught as four parameters missing from the PVA PVI structure. Every class-body `Attribute` now raises, annotated or not. The controllers in `test_p4p.py` are declared inside their test functions, so the earlier migration pass missed them. Bare ones become hints; the ones carrying metadata move into `__init__`. `SomeController.attr_1` was declared twice, int then float; the float one it actually had is what remains. `some_table.update` needed a cast that the unparameterised `AttrRW` annotation had been hiding: a `Table` is held as a plain structured ndarray. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PP7FfLHyvQYkzKs3nm8ARv
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## refactor #426 +/- ##
============================================
+ Coverage 91.25% 92.72% +1.47%
============================================
Files 72 70 -2
Lines 2892 3380 +488
============================================
+ Hits 2639 3134 +495
+ Misses 253 246 -7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Green on CI found a real bug in the first push, worth knowing about because it changes one of the rules in the diff. That also surfaced the controllers in Two things this sandbox cannot check, both now covered by real CI: the p4p tests (no PVA-capable socket family here), and that — overnight agent Generated by Claude Code |
Closes #394
ADR 0013's declarative/procedural split. Class body = declarations + decorated behaviour; instance scope = construction with data. FastCS had two declarative mechanisms — class-scope
Attributeinstances, deepcopied per instance, and bare hints that were validated but never created. This leaves one.Scope
ControllerFiller(src/fastcs/controllers/filler.py), on every controller ascontroller.filler. It reads the class hints during__init__, in two phases either side of_bind_attrsso that a hint and an@attrof the same name are one declaration rather than a clash.self.framesexists before__init__returns and the rest of__init__can reference it — ADR 0013's rule, and what makesinitialiseparallelisable.fill_attribute(name, getter=, setter=, datatype=, **meta)provisions in place, so a reference taken during__init__is the object that ends up serving the device. It validates the metadata against the declared datatype (precisionon astrraises, naming field and attribute), rejects IO the access mode has no half for, and — when you passdatatype=— checks what the device reported against what was declared.AttrR.set_getter/AttrW.set_setterare the new fill points, and refuse to overwrite IO an attribute already has.check_filled(source)reports the promised-but-missing, listing them by name and naming where the data should have come from.BaseController.check_filledwalks the tree and is whatpost_initialisenow calls.Annotatedhint's extras are carried untouched and yielded as(child, extras)by iterating the filler — the mechanism a protocol package (Example 4 — SCPI device: annotated attributes + per-attribute filler data #405'sSCPIParam) builds its own declarative vocabulary on. Core FastCS defines none, per decision 3.HintedAttribute,_validate_type_hints,_validate_hinted_member/_method/_attribute/_controller, and the deepcopy branch of_bind_attrs.@attr/@command/@scanbinding is untouched.Optional[X]hints are not required bycheck_filled; trailing-underscore names (description_: AttrR[str]) declare the attribute without the underscore, ophyd-async's convention.Attributeinstance now raises at construction, naming the attribute and both alternatives, rather than being silently deepcopied.fastcs.demo.eigertofill_attribute+check_filled,docs/snippets/static03–06, the prose examples inexplanations/controllers.md,how-to/arrange-epics-screens.md,table-waveform-data.md,typed-commands.md,update-attributes-from-device.md,wait-methods.md, and every test controller in the repo. Newdocs/explanations/declaring-attributes.mdon which spelling to use when.Instructions to reviewer on how to test:
uv run pytest tests/test_controller_filler.py tests/test_controllers.py -vuv run pytest tests/demo/test_eiger.py -v— the introspecting example, filling two declared parameters out of a discovered tree.Checks for reviewer
__init__, butstate: AttrRon the Eiger has no author-time datatype at all — the enum's members come off the wire — so there is nothing to construct. Rather than drop that case or invent a placeholder datatype, an unsubscripted hint keeps exactly the oldHintedAttributebehaviour: not created, access-mode checked when introspection adds it, andcheck_filledfails if nothing did. Say if you would rather unsubscripted hints were simply an error, which would mean changing how the Eiger example declaresstate.ControllerVectorandCommandhints are scanned and promised, not created. ophyd-async's filler constructs childDevices becauseDevice()takes no required arguments; aControllersubclass generally does, so guessing a constructor is not available to us. An emptyControllerVectorcould be created — say if you would like that one, it is a couple of lines and would letself.ramps[i] = ...work frominitialisewithout the parent building the vector.check_filledchecks existence, not IO. An attribute created from a hint and never filled is legitimate — a@scanon the parent may be what drives it, which is exactly whatdocs/how-to/update-attributes-from-device.mdrecommends — so requiring a getter would reject a documented pattern. The issue's wording ("reports promised-but-missing") is what is implemented. The cost is that a driver which forgets to fill a hinted attribute gets an attribute stuck at its default rather than an error.x = AttrR(int)with nothing else, it is now the hintx: AttrR[int], which produces the identical unfilled attribute; where it carried metadata, IO or an initial value, it moved into__init__. Worth a skim for anywhere the change of construction order matters — hinted attributes are created after__init__-assigned ones within a controller.root_attributeis deliberately still a class-bodyAttribute. It is declared onBaseControlleritself and is what a parent shows for this controller rather than an attribute of it, so it is neither the filler's to create nor covered by ADR 0013. Both_bind_attrsand the filler skip the name. Say if it should move too — it would need a different mechanism, since it must not appear in its own controller's attributes.fastcs-catioAnswered by ADR 0013's own review (question 2): no, the filler does not support building
Controllerclasses at runtime withtype(...), and catio moves to instance-level dynamic attributes instead. Nothing in this PR needs to change for that — adding attributes onto a bareControllerfrom the outside is exactly what a filler does, and there is a test for it (test_attributes_can_be_added_to_a_bare_controller_from_outside). No ADR update needed; the decision was already recorded.Notes
post_initialisekeeps its name and its place in the lifecycle; only what it calls changed. controllers: connections own health, reconnect and the retry budget #424 renames these hooks (initialise→build,post_initialise→setup) — that PR and this one both touchrunner.py's call site andbase_controller.py, so whichever merges second needs a small conflict resolution there.add_commandflattens the error it catches: aMethodhint mismatch surfaces asCannot add command method <Command object ...>rather than the "does not match defined type" message underneath. That is pre-existing (theexcept (ValueError, RuntimeError)inadd_command/add_scanre-raises with only its own message) and I have not changed it, but the test now asserts the outer message, so it is worth knowing it is there.uv run --locked tox -e pre-commit,type-checking, both green in full. As on demo: use ControllerVector for temperature ramp sub-controllers #409/demo: cut-down Eiger REST sim + introspectable controller example #410/demo: convert temperature controller to getter/setter style #411/attributes: getter/setter IO rework, remove AttributeIORef/AttributeIO #412/attributes: replace the DataType family with python types and*Metatyped dicts #418/methods: typed commands — positional arguments and a return value #419/controllers: ControllerRunner, plus native timestamps and severity on attributes #420/attributes:@attrdecorator sugar over the getter/setter constructors #423, this sandbox cannot run the PVA/p4p-backed tests (RuntimeError: Address family not supported by protocol). Excluding those,pytest src tests --ignore=tests/benchmarkingpasses 466, with only the same 10 pre-existing p4p/socket-family failures. I also built the docs offline with the version-switcher fetch stubbed out: it succeeds, and the only warnings are the intersphinx misses that come of having no network — including catching and fixing one ambiguouscheck_filledcross-reference that would have failed the real--fail-on-warningdocs job.🤖 Generated with Claude Code
https://claude.ai/code/session_01PP7FfLHyvQYkzKs3nm8ARv
Generated by Claude Code