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 3 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.59% +1.33%
============================================
Files 72 70 -2
Lines 2892 3320 +428
============================================
+ Hits 2639 3074 +435
+ 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.
| def test_each_instance_gets_its_own_attributes(): | ||
| one, two = HelloWorldController(), HelloWorldController() | ||
|
|
||
| assert one.greeting is not two.greeting | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_setting_one_instance_leaves_the_other_alone(): | ||
| one, two = HelloWorldController(), HelloWorldController() | ||
|
|
||
| await one.greeting.set("Goodbye") | ||
|
|
||
| assert await one.message.poll() == "Goodbye, world!" | ||
| assert await two.message.poll() == "Hello, world!" |
There was a problem hiding this comment.
should: these are already covered more generally in tests that will be added in #426 about ControllerFiller. So we can remove these.
There was a problem hiding this comment.
Removed in 6d2becd — both test_each_instance_gets_its_own_attributes and test_setting_one_instance_leaves_the_other_alone are gone, since per-instance attribute creation belongs with the general mechanism rather than this example.
The one test in that region now is test_the_setter_is_also_an_ordinary_method, which is new and specific to this PR: it covers that await controller.set_greeting(...) still works as a plain method after the setter rename above.
— 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
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
@attrspelling on its own, with nothing else to read past.Scope
src/fastcs/demo/hello_world.py, covering the four things@attris: a bare decorated getter as anAttrR, a@x.settermaking the pair anAttrRW, 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, 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@attr(@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
@attrsetter keeps a name of its ownAdded on review (thread
r3944915082), so that this example is the one we actually want rather than one carrying a suppression comment. It is a change to@attritself (#397's code, merged as #423) rather than to the demo, and it is what makes the module above read as it does.A setter now has its own name, as PyTango's
write_voltagedoes for avoltageattribute, rather than redeclaring the getter's name the way@propertydoes. Neither half of a read-write attribute is then a second declaration of a name the other has taken, so the# pyright: ignore[reportRedeclaration]that #423 documented as its one wart is gone — from the demo, from the tests, from the docs, and from the repo.@x.setterreturns anAttrSetterdeclaration instead of a newUnboundAttrRW. Its__set_name__replaces the getter's declaration with the read-write one in the declaring class's own namespace, so@Base.voltage.setterin a subclass still leavesBaseread-only (unchanged behaviour, same test).greetingis anAttrRW;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. An@attrwith no setter is still anAttrR, whatever the setter-less methods around it are called.docs/how-to/fastcs-for-pytango-users.mdis updated: the pairing now reads as PyTango'svoltage/write_voltage, and the note about silencing the redeclaration is replaced by one about the static type (below).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.python -c "import asyncio; from fastcs.demo.hello_world import HelloWorldController as C; c = C(); print(asyncio.run(c.message.poll()))"Checks for reviewer
@attrisAttrR[T]to a type checker. A type checker bindsgreetingat the@attrline and nothing later in the class body can change the type of a name already bound, soself.greetingreads asAttrR[str]even though it is anAttrRW[str]at runtime;self.greeting.set(...)needs anassert isinstance(..., AttrRW)to narrow, which is what the tests do. Writing through your ownset_greetingneeds nothing. This is the trade for dropping the ignore — one suppression at the declaration becomes narrowing at the use sites that reach for.set()— and it is inherent to the spelling rather than to this implementation. Say if you would rather have it the other way.@property-mirroring (§ "What is the decorator spelling?"). I have not edited the ADR, since that is a design record rather than code; happy to add an amendment section recording this, the way controllers: connections own health, reconnect and the retry budget #424 amends ADR 0016.greetingalone would not show the@attr(Polled(...), units=...)form, which is half the decorator's surface and the half a reader will reach for first.messageanduptimeare two lines each. Say if you would rather it were cut to thegreetingpair and the metadata left to tutorial 2.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.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).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 447, with only the same 10 pre-existing p4p/socket-family failures. Real CI coversdocsand PVA.🤖 Generated with Claude Code
https://claude.ai/code/session_01NPjrPBVXdgT5Btcsi1hpum