Skip to content

demo: pure-soft hello-world example using the @attr decorator - #425

Open
coretl wants to merge 3 commits into
refactorfrom
refactor-issue-398
Open

demo: pure-soft hello-world example using the @attr decorator#425
coretl wants to merge 3 commits into
refactorfrom
refactor-issue-398

Conversation

@coretl

@coretl coretl commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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 @attr spelling on its own, with nothing else to read past.

class HelloWorldController(Controller):
    def __init__(self, subject: str = "world") -> None:
        super().__init__()
        self._greeting = "Hello"
        self._subject = subject
        self._started = time.monotonic()

    @attr
    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 = value

    @attr(Polled(period=0.2))
    async def message(self) -> str:
        """The greeting as it currently reads."""
        return f"{self._greeting}, {self._subject}!"

    @attr(Polled(period=0.2), units="s", precision=1)
    async def uptime(self) -> float:
        """Seconds since the controller was constructed."""
        return time.monotonic() - self._started

Scope

  • New src/fastcs/demo/hello_world.py, covering the four things @attr is: a bare decorated getter as an AttrR, a @x.setter making the pair an AttrRW, the docstring becoming the description, and the decorator's positional schedule plus keyword metadata (Polled(period=0.2), units="s", precision=1).
  • message recomputes from greeting, 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 the Polled schedule honestly rather than polling a constant.
  • tests/demo/test_hello_world.py: access modes, inferred datatypes, docstring descriptions, the ONCE-vs-polled schedules, the metadata, and that the setter is still callable as an ordinary method.
  • The demo README's ladder already had the hello_world.py row; 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: @attr decorator sugar over the getter/setter constructors #423, so that sentence is updated.

Also in this PR: the @attr setter keeps a name of its own

Added 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 @attr itself (#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_voltage does for a voltage attribute, rather than 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 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.setter returns an AttrSetter declaration instead of a new UnboundAttrRW. Its __set_name__ replaces the getter's declaration with the read-write one in the declaring class's own namespace, so @Base.voltage.setter in a subclass still leaves Base read-only (unchanged behaviour, same test).
  • greeting is an AttrRW; set_greeting stays an ordinary bound method, so await controller.set_greeting("Goodbye") writes to the device directly while await controller.greeting.set("Goodbye") writes through the attribute and updates what clients see. An @attr with no setter is still an AttrR, whatever the setter-less methods around it are called.
  • The old same-name spelling still works, so nothing outside this repo breaks; every call site and doc here uses the named form.
  • docs/how-to/fastcs-for-pytango-users.md is updated: the pairing now reads as PyTango's voltage/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:

  1. uv run pytest tests/demo/test_hello_world.py tests/test_attr_decorator.py -v — nothing external needed.
  2. python -c "import asyncio; from fastcs.demo.hello_world import HelloWorldController as C; c = C(); print(asyncio.run(c.message.poll()))"

Checks for reviewer

  • Would the PR title make sense to a user on a set of release notes
  • A read-write @attr is AttrR[T] to a type checker. 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 reads as AttrR[str] even though it is an AttrRW[str] at runtime; self.greeting.set(...) needs an assert isinstance(..., AttrRW) to narrow, which is what the tests do. Writing through your own set_greeting needs 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.
  • ADR 0018 still writes the spelling as @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.
  • Three attributes, where the issue says "one or two". greeting alone would not show the @attr(Polled(...), units=...) form, which is half the decorator's surface and the half a reader will reach for first. message and uptime are two lines each. Say if you would rather it were cut to the greeting pair and the metadata left to tutorial 2.
  • The class is HelloWorldController, not HelloWorld. It matches TemperatureController/EigerDetector in reading as a controller, but the issue text says "hello world"; trivial to rename.
  • uptime is 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

🤖 Generated with Claude Code

https://claude.ai/code/session_01NPjrPBVXdgT5Btcsi1hpum

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
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 3cde29ae-5c89-41ae-85a9-9a39ffaa66e4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.59%. Comparing base (e73453b) to head (6d2becd).
⚠️ Report is 6 commits behind head on refactor.

Files with missing lines Patch % Lines
src/fastcs/attributes/attr_decorator.py 91.30% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`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 shihab-dls left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/fastcs/demo/hello_world.py Outdated
Comment on lines +56 to +65
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md is updated here (the PyTango pairing now reads as voltage/write_voltage, and the note about the ignore is replaced by one about the static type).

— overnight agent


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(...) and AttrW.from_setter
  • something else

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with having a narrower decorator like @AttrR and AttrRW, where @AttrR does not expect a setter, but AttrRW does. I prefer .declare(...)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Let's build that.

Comment thread tests/demo/test_hello_world.py Outdated
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants