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
5 changes: 5 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@
# A ParamSpec gets no target of its own, so `Command[P, T]` renders a
# reference to a bare `P` that resolves to nothing
("py:class", "P"),
# `UnboundGetter` resolves where it is a parameter's own annotation, but
# not where it is nested inside the `Callable[...]` an `AttrR.declare` /
# `AttrRW.declare` overload returns: a type alias is a `py:data` target,
# and a nested argument is rendered as a `py:class` reference
("py:class", "UnboundGetter"),
]
nitpick_ignore_regex = [
("py:class", r"fastcs.*.DType_T"),
Expand Down
41 changes: 41 additions & 0 deletions docs/explanations/decisions/0018-attr-decorator-sugar.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,44 @@ an introspected name and a decorated name raises.
shadowed; a clash between an introspected name and a decorated name raises.
5. **Does the getter's docstring become the `description`?** Yes, as
`@command`/`@scan` already do.

## Amendment: the spelling is `AttrR.declare`/`AttrRW.declare` (#425)

Resolved question 1 above records the spelling as `@attr` + `@x.setter`,
mirroring `@property`. Building it found a cost the ADR did not anticipate, and
review settled on a different spelling. This section records that; the rest of
the ADR stands.

**The problem.** Mirroring `@property` means two `def voltage` in one class
body, which type checkers reject for everything but the builtin `property`
(pyright: *obscured by a declaration of the same name*). Giving the setter a
name of its own - `set_voltage`, as PyTango writes `write_voltage` - removes
that, but leaves a second cost: a type checker binds `voltage` at the `@attr`
line, and nothing later in the class body can change the type of a name already
bound, so `self.voltage` reads as `AttrR[float]` even where a setter has made it
an `AttrRW[float]`, and `self.voltage.set(...)` needs narrowing at every use.

**The decision.** The decorator names the class it builds, as an alternate
constructor on that class:

```python
@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: ...
```

There is no `attr` decorator. `AttrR.declare` is read-only and has no `setter`
at all; `AttrRW.declare` expects one, and a declaration that never gets one
fails when the controller is constructed, naming the attribute. Everything else
the ADR decided - the datatype from the return annotation, the docstring as the
description, `Unpack[Meta]` keyword arguments, the leading `Polled`/`NotPolled`
schedule, no write-only decorator - is unchanged, and applies to both.

The type is now in the declaration rather than inferred from what follows it, so
`self.voltage` is an `AttrRW[float]` statically as well as at runtime and there
is no narrowing and no suppression comment anywhere in the repo.
84 changes: 41 additions & 43 deletions docs/how-to/fastcs-for-pytango-users.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,98 +23,96 @@ class PowerSupply(Device):
return 2.5
```

FastCS says the same thing with `@attr`:
FastCS says the same thing with `AttrR.declare`:

```python
from fastcs.attributes import attr
from fastcs.attributes import AttrR
from fastcs.controllers import Controller


class PowerSupply(Controller):
@attr
@AttrR.declare
async def voltage(self) -> float:
return 2.5
```

Two differences to notice:
Three differences to notice:

- The getter is `async`. FastCS controllers run on one event loop, so a getter
that talks to a device awaits it rather than blocking every other attribute.
- The datatype comes from the return annotation. There is no `dtype=` keyword to
keep in step with the code - `-> float` is one real annotation, checked by your
type checker as well as by FastCS.
- The decorator names the class it builds. `AttrR.declare` is a value that can
only be read; `AttrRW.declare`, below, is one that can be written too. What the
declaration says is what your type checker sees at every use of the attribute.

## Writing as well as reading

PyTango pairs a getter with a `@x.write` method (or `@x.setter` in the
`attribute` decorator form). FastCS mirrors `@property`:
PyTango pairs a `voltage` attribute with a separately named `write_voltage`
method. FastCS does the same, with the pairing made by a decorator rather than
by the name:

```python
class PowerSupply(Controller):
@attr(units="V", precision=3)
@AttrRW.declare(units="V", precision=3)
async def voltage(self) -> float:
"""Output voltage."""
return float(await self._conn.query("V?"))

@voltage.setter
async def voltage(self, value: float) -> None:
async def set_voltage(self, value: float) -> None:
await self._conn.send(f"V={value}")
```

A getter alone gives you a read-only `AttrR`; adding a setter makes the same
name an `AttrRW`. There is no write-only decorator - a write-only attribute is
rare enough to be written longhand as `AttrW(setter=...)`.

The getter's docstring becomes the attribute's description, and keyword
arguments to `@attr` are the attribute's metadata - `units`, `precision`,
`AttrRW.declare` says the attribute can be written, and expects the
`@voltage.setter` method that says how; a declaration that never gets one fails
when the controller is constructed, naming the attribute. `AttrR.declare` is
read-only and cannot be given a setter at all. There is no write-only decorator
- a write-only attribute is rare enough to be written longhand as
`AttrW(setter=...)`.

The setter keeps a name of its own - `set_voltage` here, but it can be called
whatever reads best - so the two halves of one attribute are never two
declarations of one name. It also stays an ordinary method, so
`await self.set_voltage(2.5)` writes to the device directly, while
`await self.voltage.set(2.5)` writes through the attribute and updates what
clients see.

The getter's docstring becomes the attribute's description, and the decorator's
keyword arguments are the attribute's metadata - `units`, `precision`,
`limits`, `group`, `description`. They are checked against the datatype the
getter returns, so `precision` on a `-> str` getter is an error rather than a
field that is silently ignored.

:::{note}
Type checkers special-case the builtin `property` but not decorators that
imitate it, so pyright reports the getter as *obscured by a declaration of the
same name*, and mypy as *already defined*. The two declarations are deliberate,
so silence it at the getter:

```python
@attr(units="V")
async def voltage(self) -> float: # pyright: ignore[reportRedeclaration]
...
```

Only read-write attributes need this. A read-only `@attr` declares its name
once and needs nothing.
:::

## Deciding when a value is read

PyTango polls an attribute on a period configured per device, outside the code.
In FastCS the schedule is part of the declaration, and is the same
`Polled`/`NotPolled` vocabulary the procedural form uses:

```python
from fastcs.attributes import NotPolled, Polled, attr
from fastcs.attributes import AttrR, NotPolled, Polled


class PowerSupply(Controller):
@attr(Polled(period=0.5), units="V")
@AttrR.declare(Polled(period=0.5), units="V")
async def voltage(self) -> float:
"""Read every half second, because the device changes it."""
return float(await self._conn.query("V?"))

@attr
@AttrR.declare
async def serial_number(self) -> str:
"""Read once, when the controller connects."""
return await self._conn.query("*IDN?")

@attr(NotPolled())
@AttrR.declare(NotPolled())
async def last_error(self) -> str:
"""Never read on a schedule - only when something asks for it."""
return await self._conn.query("ERR?")
```

A bare `@attr` means read once, at connect - the same default a bare
A bare `@AttrR.declare` means read once, at connect - the same default a bare
`getter=` has. See [](./update-attributes-from-device.md) for the whole picture,
including devices that push values at you rather than being polled.

Expand All @@ -137,21 +135,21 @@ class PowerSupply(Controller):
See [](./typed-commands.md) for arguments and return values, and which
transports can serve them.

## When not to use `@attr`
## When not to use the decorators

`@attr` is the simple case: one attribute, one device call, known at the time
you write the class. It is sugar over the procedural form, and there are two
other spellings for when it stops fitting:
`AttrR.declare`/`AttrRW.declare` are the simple case: one attribute, one device
call, known at the time you write the class. They are sugar over the procedural
form, and there are two other spellings for when they stop fitting:

- **The attribute needs more than a getter and a setter** - a shared connection
object, several attributes built in a loop, values that come from one
request - build them in `__init__` with `AttrR(getter=...)` /
`AttrRW(getter=..., setter=...)` directly. `@attr` degrades into exactly that
form, so nothing is lost by moving.
`AttrRW(getter=..., setter=...)` directly. A declaration degrades into exactly
that form, so nothing is lost by moving.
- **The device describes itself** - the attributes are discovered by asking the
device what it has, rather than written out. Declare what your code refers to
as type hints and let the controller fill them in at initialisation. See
[](../tutorials/dynamic-drivers.md).

There is no free-function `attr()` factory: outside a class body, write the
constructor.
Both decorators only work in a class body, where the getter is a method of the
controller: outside one, write the constructor.
6 changes: 3 additions & 3 deletions src/fastcs/attributes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
from .attr_decorator import AttrSetter as AttrSetter
from .attr_decorator import UnboundAttr as UnboundAttr
from .attr_decorator import UnboundAttrRW as UnboundAttrRW
from .attr_decorator import UnboundGetter as UnboundGetter
from .attr_decorator import UnboundSetter as UnboundSetter
from .attr_decorator import attr as attr
from .attr_r import AttrR as AttrR
from .attr_r import Getter as Getter
from .attr_r import NotPolled as NotPolled
from .attr_r import Polled as Polled
from .attr_r import Schedule as Schedule
from .attr_r import UnboundGetter as UnboundGetter
from .attr_rw import AttrRW as AttrRW
from .attr_w import AttrW as AttrW
from .attr_w import Setter as Setter
from .attr_w import UnboundSetter as UnboundSetter
from .attribute import Attribute as Attribute
from .attribute import AttributeAccessMode as AttributeAccessMode
from .hinted_attribute import HintedAttribute as HintedAttribute
Expand Down
Loading
Loading