demo: pure-soft hello-world example using the @attr decorator - #425
demo: pure-soft hello-world example using the @attr decorator#425coretl wants to merge 5 commits into
@attr decorator#425Conversation
The first rung of the demo ladder, and the only one with no device behind it: every value lives in the controller object, so it runs with no simulator, no socket and no external process. Shows the `@attr` spelling on its own - a bare decorated getter as an `AttrR`, a `@x.setter` making the pair an `AttrRW`, a docstring becoming the description, and `@attr(Polled(period=...), units=..., precision=...)` as the schedule and metadata. `message` recomputes from `greeting`, so setting one attribute visibly moves another without a device to do it. Closes #398 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 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## refactor #425 +/- ##
============================================
+ Coverage 91.25% 92.63% +1.38%
============================================
Files 72 70 -2
Lines 2892 3341 +449
============================================
+ Hits 2639 3095 +456
+ Misses 253 246 -7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`pre-commit run --all-files` skips files git does not track, so neither the new module nor its test was checked before the first push: ruff wanted the long description assertion wrapped. The module docstring pointed at `fastcs.demo.temperature_attr` in single backticks, which the default `any` role resolved to both the module and its generated API page. Made a literal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PP7FfLHyvQYkzKs3nm8ARv
shihab-dls
left a comment
There was a problem hiding this comment.
The example has brought up a design choice we must change regarding @attr setters, so this must be changed in this PR, such that the example is correct.
| async def greeting(self) -> str: # pyright: ignore[reportRedeclaration] | ||
| """The word to greet with.""" | ||
| # A bare `@attr` is read once, when the controller connects, which is | ||
| # what a value only changes because you changed it needs. | ||
| return self._greeting | ||
|
|
||
| @greeting.setter | ||
| async def greeting(self, value: str) -> None: | ||
| self._greeting = value | ||
|
|
There was a problem hiding this comment.
must: we should not have to # pyright: ignore[reportRedeclaration] to make a setter for our @attr. We should instead copy the PyTango approach rather than @property, where HelloWorldController.greeting is an AttrRW and set_greeting (or any method with @greeting.setter) is the method that sets HelloWorldController.greeting. such as:
| async def greeting(self) -> str: # pyright: ignore[reportRedeclaration] | |
| """The word to greet with.""" | |
| # A bare `@attr` is read once, when the controller connects, which is | |
| # what a value only changes because you changed it needs. | |
| return self._greeting | |
| @greeting.setter | |
| async def greeting(self, value: str) -> None: | |
| self._greeting = value | |
| async def greeting(self) -> str: # pyright: ignore[reportRedeclaration] | |
| """The word to greet with.""" | |
| # A bare `@attr` is read once, when the controller connects, which is | |
| # what a value only changes because you changed it needs. | |
| return self._greeting | |
| @greeting.setter | |
| async def set_greeting(self, value: str) -> None: | |
| self._greeting = value | |
Although this is beyond the original scope of this PR, support this change in this PR, and amend the PR description to mention that this PR will add this as a fix such that we have our intended example controller. We should ensure that an @attr without a setter (named whatever), ends up as an AttrR though.
There was a problem hiding this comment.
Done in 6d2becd, and the PR description now covers it.
@x.setter returns an AttrSetter declaration instead of a new UnboundAttrRW. Its __set_name__ replaces the getter's declaration — in the declaring class's own namespace, so @Base.voltage.setter in a subclass still leaves Base read-only — with the read-write one. So HelloWorldController.greeting is an AttrRW, set_greeting stays an ordinary callable method (await controller.set_greeting("Goodbye") works and is tested), and an @attr with no setter is still an AttrR. No # pyright: ignore[reportRedeclaration] anywhere in the repo now. The old same-name spelling still works, so nothing else needed changing, but every call site and doc uses the named form.
One consequence to flag, because it is inherent to the spelling rather than to how I implemented it: a type checker binds greeting at the @attr line, and nothing later in the class body can change the type of a name already bound. So self.greeting is AttrR[str] statically even though it is an AttrRW[str] at runtime, and self.greeting.set(...) needs an assert isinstance(..., AttrRW) to narrow (which is what the tests do). Writing through your own set_greeting needs nothing. That is the trade for dropping the ignore — it moves the one suppression at the declaration to narrowing at the use sites that reach for .set(), which I think is the better side of it, but say if you would rather have it the other way.
Two follow-ups I have not done, as they look like your calls rather than mine:
- ADR 0018 still writes the spelling as
@property-mirroring (§ "What is the decorator spelling?"). Happy to add an amendment section recording this, the way controllers: connections own health, reconnect and the retry budget #424 amends ADR 0016 — say the word. docs/how-to/fastcs-for-pytango-users.mdis updated here (the PyTango pairing now reads asvoltage/write_voltage, and the note about the ignore is replaced by one about the static type).
— overnight agent
Generated by Claude Code
There was a problem hiding this comment.
Hmm, I'd prefer to put the type in the name rather than use @attr then have to type narrow. Considered attr.rw but that's another namespace to learn. I'd prefer the decorator to be attached to the type it creates. How about an alternate constructor on the class?
@AttrR.declare
async def uptime(self) -> float: ...
@AttrRW.declare(Polled(period=0.2), units="s")
async def greeting(self) -> str: ...
@greeting.setter
async def set_greeting(self, value: str) -> None: ...Claude checked this is clean under both pyright and mypy with no suppressions anywhere. (It needs to be a staticmethod with its own TypeVar; a classmethod using the class's TypeVar leaves pyright at AttrR[Unknown] while mypy resolves it fine.)
@shihab-dls which spelling do you prefer?
AttrRW.declare(...)AttrRW.of(...)AttrRW.wrap(...)AttrRW.from_getter(...)andAttrW.from_setter- something else
There was a problem hiding this comment.
I agree with having a narrower decorator like @AttrR and AttrRW, where @AttrR does not expect a setter, but AttrRW does. I prefer .declare(...)
There was a problem hiding this comment.
Agreed. Let's build that.
There was a problem hiding this comment.
Built in 45f0db2, as .declare.
@AttrR.declare
async def uptime(self) -> float:
"""Seconds since the controller was constructed."""
return time.monotonic() - self._started
@AttrRW.declare
async def greeting(self) -> str:
"""The word to greet with."""
return self._greeting
@greeting.setter
async def set_greeting(self, value: str) -> None:
self._greeting = valueself.greeting is now an AttrRW[str] to pyright, not just at runtime. That was the point, and it holds — tests/demo/test_hello_world.py asserts it with assert_type(controller.greeting, AttrRW[str]), so the type-checking job fails if it ever drifts. Every assert isinstance(..., AttrRW) narrowing that existed only to reach .set() is gone, and there is no # pyright: ignore left in the repo for either half of a read-write attribute.
AttrR.declare genuinely does not expect a setter, as you asked: setter lives on UnboundAttrRW alone, so @serial.setter on an AttrR.declare getter is an AttributeError, not a quiet promotion to read-write. AttrRW.declare expects one, and a declaration that never gets one raises when the controller is constructed, naming the attribute and offering both ways out.
It is a staticmethod with its own Declared_T TypeVar, as you found — a class-scoped TypeVar leaves pyright at AttrR[Unknown]. Declared_T sits in datatypes/types.py next to Inferred_T, which exists for the same reason on the constructor overloads.
Three things worth your eye:
attris gone, rather than kept as an alias. Two spellings for one thing is what the exchange above was trying to avoid, and it is pre-1.0 with the decorator only three days old (attributes:@attrdecorator sugar over the getter/setter constructors #423). Everything in the repo is migrated: the demo,tests/test_attr_decorator.py, anddocs/how-to/fastcs-for-pytango-users.md. Say if you would ratherattrstayed as a deprecated alias for a release.- A read-only declaration can no longer be promoted by a subclass.
test_setter_does_not_leak_onto_the_class_it_was_inherited_fromused to declare@attronBaseand@Base.voltage.setterinChild; the base now declaresAttrRW.declareand the child supplies the setter, which still proves the thing the test is for — the setter lands inChild's namespace andBase.voltagedoes not have one. What is no longer expressible is a base that is read-only and instantiable while a subclass adds writing. That follows from "AttrRdoes not expect a setter"; say if you want it back and the fix isAttrR.declarekeeping asetterthat returns anUnboundAttrRW. - ADR 0018 has an amendment, since the ADR's resolved question 1 records
@attr+@x.setterand a reader would otherwise be given a spelling that does not exist. It records the redeclaration problem, the narrowing that replaced it, and this decision; the rest of the ADR stands, and ADRs 0013/0014 are left alone as the records of their own moment.
Verified with uv run --locked tox -e pre-commit,type-checking (both green in full) and the test suite — 451 passing, with only the 10 pre-existing p4p/socket-family failures this sandbox always has. I have not run mypy: it is not in this repo's dev dependencies or its tox envs, so I can only report pyright.
— overnight agent
Generated by Claude Code
An `@attr` setter now keeps a name of its own - `set_greeting` for a `greeting` attribute, as PyTango's `write_voltage` does - instead of redeclaring the getter's name the way `@property` does. Neither half of a read-write attribute is then a second declaration of a name the other has taken, so no `# pyright: ignore[reportRedeclaration]` is needed anywhere. `@x.setter` returns an `AttrSetter` declaration rather than a new `UnboundAttrRW`. When the class is created it replaces the getter's declaration, in that class's own namespace, with the read-write one, so `greeting` binds an `AttrRW` while `set_greeting` stays callable as an ordinary method. A subclass writing `@Base.voltage.setter` still leaves `Base` read-only, and an `@attr` with no setter is still an `AttrR`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPjrPBVXdgT5Btcsi1hpum
Replaces the `@attr` decorator with an alternate constructor on the class
the decorator builds, as agreed on the review thread. The type is now in
the declaration rather than inferred from what follows it:
@AttrR.declare
async def uptime(self) -> float: ...
@AttrRW.declare(Polled(period=0.2), units="s")
async def voltage(self) -> float: ...
@voltage.setter
async def set_voltage(self, value: float) -> None: ...
`AttrR.declare` is read-only and carries no `setter` at all;
`AttrRW.declare` expects one, and a declaration never given a setter
fails when the controller is constructed, naming the attribute.
`self.voltage` is now an `AttrRW[float]` to a type checker as well as at
runtime, so `.set()` needs no `assert isinstance` narrowing - the trade
the `@attr` spelling forced. There is no suppression comment left in the
repo for either half of a read-write attribute.
Everything else the decorator does is unchanged: the datatype from the
getter's return annotation, the docstring's first paragraph as the
description, `Unpack[Meta]` keyword arguments, and the leading
`Polled`/`NotPolled` schedule.
ADR 0018 gains an amendment recording the spelling and why it changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAWLNMnpQSJKbsZXa3NDZx
The docs job failed with four nitpicky warnings, all of them the `UnboundGetter` in an `AttrR.declare`/`AttrRW.declare` overload. Two of them were a real problem: the alias was defined in `attr_decorator.py` and imported into `attr_r.py`/`attr_rw.py` only under `TYPE_CHECKING`, so sphinx could not evaluate the annotation and fell back to a bare, unresolvable name. `UnboundGetter` now lives in `attr_r.py` next to `Getter`, and `UnboundSetter` in `attr_w.py` next to `Setter`, which is where they belong and removes an import cycle rather than working around one; `attr_decorator.py` imports them. The other two are nested inside the `Callable[...]` the parameterised overload returns, where a type alias cannot answer the `py:class` reference sphinx emits, so they get a `nitpick_ignore` entry saying so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAWLNMnpQSJKbsZXa3NDZx
Closes #398
The first rung of the demo ladder, and the only one with no device behind it. Every value lives in the controller object, so this module runs with no simulator, no socket and no external process — which is the point: it shows the declarative spelling on its own, with nothing else to read past.
Scope
src/fastcs/demo/hello_world.py, covering the four things the decorator is: a bare decorated getter as anAttrR, anAttrRW.declareplus@x.setterpair, the docstring becoming the description, and the decorator's positional schedule plus keyword metadata (Polled(period=0.2),units="s",precision=1).messagerecomputes fromgreeting, so setting one attribute visibly moves another. That is the one thing a soft example can otherwise not show — a value changing for a reason other than you writing it — and it earns thePolledschedule honestly rather than polling a constant.tests/demo/test_hello_world.py: access modes, inferred datatypes, docstring descriptions, theONCE-vs-polled schedules, the metadata, the static types, and that the setter is still callable as an ordinary method.hello_world.pyrow; its "baselines vs framework PRs" paragraph said this module was still waiting on the decorator (@attr decorator sugar over getter/setter constructors #397), which merged as attributes:@attrdecorator sugar over the getter/setter constructors #423, so that sentence is updated.Also in this PR: the decorator names the class it builds
Added on review (thread
r3944915082and its follow-up), so that this example is the one we actually want. It is a change to the decorator itself (#397's code, merged as #423) rather than to the demo, and it is what makes the module above read as it does. It landed in two steps, and the second supersedes the first:The setter got a name of its own, as PyTango's
write_voltagedoes for avoltageattribute, rather than redeclaring the getter's name the way@propertydoes. That removed the# pyright: ignore[reportRedeclaration]attributes:@attrdecorator sugar over the getter/setter constructors #423 documented as its one wart, but left a second cost: a type checker binds the name at the decorator line and nothing later in the class body can change it, soself.greetingread asAttrR[str]even though it was anAttrRW[str]at runtime, and.set()neededassert isinstance(...)narrowing.@attris replaced byAttrR.declare/AttrRW.declare— an alternate constructor on the class the decorator builds, which is where @coretl and @shihab-dls landed on the thread. The type is now in the declaration rather than inferred from what follows it, so both costs are gone:AttrR.declareis read-only and carries nosetterat all —@serial.setteron one is anAttributeError, not a quiet promotion to read-write.AttrRW.declareexpects a setter, and a declaration that never gets one raises when the controller is constructed, naming the attribute.self.greetingis anAttrRW[str]to pyright as well as at runtime.tests/demo/test_hello_world.pypins that withassert_type, so the type-checking job fails if it drifts.set_greetingstays an ordinary bound method, soawait controller.set_greeting("Goodbye")writes to the device directly whileawait controller.greeting.set("Goodbye")writes through the attribute and updates what clients see.# pyright: ignoreleft in the repo for either half of a read-write attribute.@x.setterstill replaces the getter's declaration in the declaring class's own namespace, so@Base.voltage.setterin a subclass leavesBasewithout one (unchanged behaviour, same test).ADR 0018 gains an amendment recording the spelling and why it changed, since its resolved question 1 writes
@attr+@x.setterand a reader would otherwise be given a spelling that no longer exists. ADRs 0013 and 0014 mention@attrin passing and are left alone as records of their own moment.docs/how-to/fastcs-for-pytango-users.mdis updated throughout: the PyTango pairing reads asvoltage/write_voltage, and the note about narrowing is gone rather than reworded, because there is nothing left to narrow.Instructions to reviewer on how to test:
uv run pytest tests/demo/test_hello_world.py tests/test_attr_decorator.py -v— nothing external needed.uv run --locked tox -e type-checking— theassert_typecalls in the demo tests are what prove the static type.uv run python -c "import asyncio; from fastcs.demo.hello_world import HelloWorldController as C; c = C(); print(asyncio.run(c.message.poll()))"Checks for reviewer
attris removed rather than kept as an alias. Two spellings for one thing is what the review exchange was trying to avoid, and it is pre-1.0 with the decorator only days old (attributes:@attrdecorator sugar over the getter/setter constructors #423), so everything in the repo is migrated instead. Say if you would ratherattrstayed as a deprecated alias for a release.AttrR.declarehas nosetter, which is what "@AttrRdoes not expect a setter" asks for — but it means a base that is read-only and instantiable, with a subclass adding writing, is no longer expressible. The subclass-namespace test now declaresAttrRW.declareon the base and supplies the setter in the child, which still proves the thing it is for. Say if you want the promotion back.AttrRW.declarewith no setter raises rather than binding a soft attribute. The proceduralAttrRW(str)with no setter is legitimate —set()pushes straight to the readback — so this could have been allowed. A decorated getter is device IO by construction, though, so a silent soft round-trip is almost certainly a forgotten setter. Say if you would rather it were permitted.greetingalone would not show the@AttrR.declare(Polled(...), units=...)form, which is half the decorator's surface and the half a reader will reach for first.messageanduptimeare two lines each.HelloWorldController, notHelloWorld. It matchesTemperatureController/EigerDetectorin reading as a controller, but the issue text says "hello world"; trivial to rename.uptimeis a soft value that moves on its own (time.monotonic()). It is the only thing here with no user-visible cause, which is what makes it a fair demonstration of polling — but it is also the one attribute whose value is not reproducible, so its test only asserts it does not go backwards.Notes
test_each_instance_gets_its_own_attributes,test_setting_one_instance_leaves_the_other_alone) are removed on review, as controllers:ControllerFiller, and one declarative mechanism for attributes #426 covers that generally.ControllerFiller, and one declarative mechanism for attributes #426, which usesattrintests/test_controller_filler.pyand readsUnboundAttrinbase_controller._bind_attrs. Both are independent branches offrefactor; whichever merges second needs those decorators renamed, which is mechanical.src/fastcs/demo/fastcs.yaml. That file launches the temperature controllers over GraphQL and EPICS CA on fixed ports, and a soft example does not need a transport to be the thing it demonstrates; adding it would also churn the checked-inschema.json. Say if you would like it launchable.literalincluderegion markers yet — per the demo README those are added with each module's tutorial in the docs pass (DOCS: Rewrite tutorials around the examples/ controllers (one per example, single-sourced) #408).declareis astaticmethodwith its ownDeclared_TTypeVar, which sits indatatypes/types.pynext toInferred_T: a class-scoped TypeVar leaves pyright resolving the decorator toAttrR[Unknown].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 rundocs(needs outbound network) or the PVA/p4p-backed tests (RuntimeError: Address family not supported by protocol). Excluding those,pytest src tests --ignore=tests/benchmarkingpasses 451, with only the same 10 pre-existing p4p/socket-family failures. Real CI coversdocsand PVA. mypy is not in this repo's dev dependencies or tox envs, so only pyright has been run.🤖 Generated with Claude Code
https://claude.ai/code/session_01JAWLNMnpQSJKbsZXa3NDZx