diff --git a/docs/conf.py b/docs/conf.py index 0325064f..4f74d36e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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"), diff --git a/docs/explanations/decisions/0018-attr-decorator-sugar.md b/docs/explanations/decisions/0018-attr-decorator-sugar.md index 196775cb..f9303e65 100644 --- a/docs/explanations/decisions/0018-attr-decorator-sugar.md +++ b/docs/explanations/decisions/0018-attr-decorator-sugar.md @@ -8,7 +8,12 @@ Date: 2026-07-20 ## Status -Proposed +Superseded + +The `@attr` spelling described in this historical decision is no longer +available. It was superseded by `AttrR.declare` and `AttrRW.declare`, which +make the access mode explicit while retaining the getter and setter declaration +model. The amendment below records the replacement spelling. ## Context @@ -45,6 +50,10 @@ removes the latter but keeps `@command`/`@scan`. ## Decision +> **Historical decision, superseded:** The `@attr` examples and rules below +> record the design considered at the time. Use `AttrR.declare` or +> `AttrRW.declare` in current code instead. + Add `@attr` as pure sugar over `AttrR`/`AttrRW` plus generated getter/setter callables ([ADR 14](0014-attribute-io-rw-rework.md)), built on the same `Unbound*`-style bind machinery as `@command`/`@scan` — fresh objects per @@ -154,3 +163,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. diff --git a/docs/how-to/fastcs-for-pytango-users.md b/docs/how-to/fastcs-for-pytango-users.md index 1f82ac09..c70c9413 100644 --- a/docs/how-to/fastcs-for-pytango-users.md +++ b/docs/how-to/fastcs-for-pytango-users.md @@ -23,70 +23,68 @@ 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. @@ -94,27 +92,27 @@ 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. @@ -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. diff --git a/src/fastcs/attributes/__init__.py b/src/fastcs/attributes/__init__.py index 80b84841..40f28135 100644 --- a/src/fastcs/attributes/__init__.py +++ b/src/fastcs/attributes/__init__.py @@ -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 diff --git a/src/fastcs/attributes/attr_decorator.py b/src/fastcs/attributes/attr_decorator.py index 905e7750..61f26a70 100644 --- a/src/fastcs/attributes/attr_decorator.py +++ b/src/fastcs/attributes/attr_decorator.py @@ -1,13 +1,30 @@ -"""``@attr`` decorator sugar over the getter/setter constructors (ADR 0018). - -``@attr`` is the one-decorated-getter spelling a PyTango user expects, written -over the same machinery as the procedural ``AttrR(getter=...)`` / -``AttrRW(getter=..., setter=...)`` form rather than beside it. It is a -decorator only - there is no free-function ``attr()`` factory, and no -``@attr_r``/``@attr_rw``: an ``AttrR`` is a decorated getter, an ``AttrRW`` is -that plus a ``@x.setter``, and a write-only ``AttrW`` is rare enough to write +"""Decorator sugar over the getter/setter constructors (ADR 0018). + +``AttrR.declare``/``AttrRW.declare`` are the one-decorated-getter spelling a +PyTango user expects, written over the same machinery as the procedural +``AttrR(getter=...)`` / ``AttrRW(getter=..., setter=...)`` form rather than +beside it:: + + class PowerSupply(Controller): + @AttrRW.declare(Polled(period=0.5), units="V") + async def voltage(self) -> float: + \"\"\"Output voltage.\"\"\" + return float(await self._conn.query("V?")) + + @voltage.setter + async def set_voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") + +The decorator names the class it builds, so what a reader - and a type checker +- sees at the declaration is what the attribute is: ``AttrR.declare`` takes a +getter and nothing else, ``AttrRW.declare`` takes a getter and expects a +``@x.setter`` to go with it. A write-only ``AttrW`` is rare enough to write longhand. +The setter carries a name of its own, as PyTango's ``write_voltage`` does +rather than ``@property``'s second ``def voltage``, so neither half of a +read-write attribute redeclares a name the other has already taken. + Binding follows ``@command``/``@scan``: the class body holds an `UnboundAttr` describing the attribute, and each controller instance gets a fresh ``AttrR``/``AttrRW`` built from it at construction time. Nothing is deepcopied @@ -21,25 +38,25 @@ from collections.abc import Awaitable, Callable from inspect import Parameter, Signature, getdoc, signature from types import MethodType -from typing import Any, Generic, Unpack, cast, overload +from typing import Any, Generic, cast, overload from fastcs.attributes._infer_datatype import ( _datatype_for_annotation, _unwrap_update_annotation, ) -from fastcs.attributes.attr_r import AttrR, NotPolled, Polled, Schedule +from fastcs.attributes.attr_r import ( + AttrR, + NotPolled, + Polled, + Schedule, + UnboundGetter, +) from fastcs.attributes.attr_rw import AttrRW +from fastcs.attributes.attr_w import UnboundSetter from fastcs.attributes.update import Update from fastcs.datatypes import DType_T, Meta from fastcs.util import Controller_T -UnboundGetter = Callable[[Controller_T], Awaitable[DType_T | Update[DType_T]]] -"""An ``@attr`` getter, taking the `Controller` it will be bound to as ``self``""" -UnboundSetter = Callable[ - [Controller_T, DType_T], Awaitable[None | DType_T | Update[DType_T]] -] -"""An ``@x.setter`` setter, taking the `Controller` it will be bound to as ``self``""" - def _type_name(datatype: Any) -> str: """A datatype as it was most likely written, to name it in an error.""" @@ -59,7 +76,7 @@ def _summary(docstring: str | None) -> str | None: def _method_signature(fn: Callable) -> Signature: - """Resolve the signature of an async ``@attr`` getter or setter. + """Resolve the signature of an async declared getter or setter. Args: fn: The decorated function @@ -78,7 +95,7 @@ def _method_signature(fn: Callable) -> Signature: class UnboundAttr(Generic[Controller_T, DType_T]): - """An ``@attr``-decorated getter, and the metadata that goes with it. + """An ``AttrR.declare``-decorated getter, and the metadata that goes with it. An instance of this class lives in the `Controller` class body, in place of the method it decorates. It is a declaration rather than an attribute: each @@ -99,6 +116,7 @@ def __init__( schedule: Schedule[DType_T] | None = None, meta: Meta | None = None, setter: UnboundSetter[Controller_T, DType_T] | None = None, + name: str | None = None, ) -> None: try: getter_signature = _method_signature(getter) @@ -109,25 +127,25 @@ def __init__( ): raise TypeError("must be a method taking self") except TypeError as error: - raise TypeError(f"@attr getter {getter.__qualname__} {error}") from error + raise TypeError(f"Declared getter {getter.__qualname__} {error}") from error annotation = _unwrap_update_annotation(getter_signature.return_annotation) datatype = _datatype_for_annotation(annotation) if datatype is None: if annotation is not Signature.empty: raise TypeError( - f"@attr getter {getter.__qualname__} must annotate a supported " + f"Declared getter {getter.__qualname__} must annotate a supported " f"datatype, got {_type_name(annotation)}" ) raise TypeError( - f"@attr getter {getter.__qualname__} must annotate the datatype " + f"Declared getter {getter.__qualname__} must annotate the datatype " "the attribute holds as its return type, for example `-> float`" ) if isinstance(schedule, Polled | NotPolled) and schedule.getter is not None: raise TypeError( - f"The schedule given to @attr on {getter.__qualname__} already " - "has a getter; pass a bare Polled(period=...) or NotPolled()" + f"The schedule given to the declaration of {getter.__qualname__} " + "already has a getter; pass a bare Polled(period=...) or NotPolled()" ) self._getter = getter @@ -135,7 +153,7 @@ def __init__( self._schedule = schedule self._datatype = datatype self._meta: dict[str, Any] = dict(meta or {}) - self._name = getter.__name__ + self._name = name or getter.__name__ def __set_name__(self, owner: type, name: str) -> None: self._name = name @@ -155,7 +173,7 @@ def __get__(self, instance: Any, owner: type | None = None, /) -> Any: return self raise AttributeError( - f"Attribute '{self._name}' does not exist yet. An @attr declaration " + f"Attribute '{self._name}' does not exist yet. A declaration " "becomes an attribute when the controller is constructed, so it " "cannot be reached before Controller.__init__ has run." ) @@ -165,66 +183,14 @@ def datatype(self) -> Any: """The datatype inferred from the getter's return annotation.""" return self._datatype + @property + def name(self) -> str: + """The name this declaration has in the `Controller` class body.""" + return self._name + def has_setter(self) -> bool: return self._setter is not None - def setter( - self, fn: UnboundSetter[Controller_T, DType_T] - ) -> UnboundAttrRW[Controller_T, DType_T]: - """Declare the writer half, making this an ``AttrRW``. - - Mirrors ``@property``/``@x.setter``, so a read-write attribute is one - name with two decorated methods:: - - @voltage.setter - async def voltage(self, value: float) -> None: - await self._conn.send(f"V={value}") - - Args: - fn: The setter, taking ``self`` and the value to apply - - Returns: - A new `UnboundAttrRW` with the setter attached. This one is left - alone, so a subclass declaring a setter does not also give one to - the base class it inherited the getter from. - - Raises: - TypeError: If the setter is not an async method taking a value, or - annotates a value of a different datatype to the getter's - - """ - if self._setter is not None: - raise TypeError( - f"@attr getter {self._getter.__qualname__} already has a setter" - ) - - try: - setter_signature = _method_signature(fn) - setter_parameters = list(setter_signature.parameters.values()) - if len(setter_parameters) != 2 or any( - parameter.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD) - for parameter in setter_parameters - ): - raise TypeError("must be a method taking self and the value to set") - except TypeError as error: - raise TypeError(f"@attr setter {fn.__qualname__} {error}") from error - - value = list(setter_signature.parameters.values())[1] - if value.annotation is not Signature.empty: - if _datatype_for_annotation(value.annotation) is not self._datatype: - raise TypeError( - f"@attr setter {fn.__qualname__} takes a " - f"{_type_name(value.annotation)}, but its getter returns a " - f"{_type_name(self._datatype)}" - ) - - return UnboundAttrRW( - self._getter, - schedule=self._schedule, - meta=cast(Meta, self._meta), - setter=fn, - ) - def bind(self, controller: Controller_T) -> AttrR[DType_T]: """Build the attribute this declares, for one `Controller` instance. @@ -265,10 +231,11 @@ def __repr__(self) -> str: class UnboundAttrRW(UnboundAttr[Controller_T, DType_T]): - """An `UnboundAttr` that has been given a setter, so it binds an ``AttrRW``. + """An ``AttrRW.declare``-decorated getter, which binds an ``AttrRW``. - A separate class only so that a declaration carrying a setter reads as the - ``AttrRW`` it becomes, and one without it as an ``AttrR``. + A separate class so that a read-write declaration reads as the ``AttrRW`` + it becomes, and a read-only one as the ``AttrR``. It is also what carries + `setter`: only a declaration that said it was read-write can be given one. """ @overload @@ -284,73 +251,197 @@ def __get__( def __get__(self, instance: Any, owner: type | None = None, /) -> Any: return super().__get__(instance, owner) + def setter( + self, fn: UnboundSetter[Controller_T, DType_T] + ) -> AttrSetter[Controller_T, DType_T]: + """Declare the writer half of this attribute. + + The setter keeps a name of its own, as PyTango's ``write_voltage`` does + for a ``voltage`` attribute, so the two halves of one attribute are + never two declarations of one name:: + + @voltage.setter + async def set_voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") + + Args: + fn: The setter, taking ``self`` and the value to apply + + Returns: + An `AttrSetter` declaration, which replaces the getter's + declaration with one carrying this setter when the class is + created. This one is left alone, so a subclass declaring a setter + does not also give one to the base class it inherited the getter + from. + + Raises: + TypeError: If the setter is not an async method taking a value, or + annotates a value of a different datatype to the getter's + + """ + if self._setter is not None: + raise TypeError( + f"Declared getter {self._getter.__qualname__} already has a setter" + ) + + try: + setter_signature = _method_signature(fn) + setter_parameters = list(setter_signature.parameters.values()) + if len(setter_parameters) != 2 or any( + parameter.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD) + for parameter in setter_parameters + ): + raise TypeError("must be a method taking self and the value to set") + except TypeError as error: + raise TypeError(f"Declared setter {fn.__qualname__} {error}") from error + + value = list(setter_signature.parameters.values())[1] + if value.annotation is not Signature.empty: + if _datatype_for_annotation(value.annotation) != self._datatype: + raise TypeError( + f"Declared setter {fn.__qualname__} takes a " + f"{_type_name(value.annotation)}, but its getter returns a " + f"{_type_name(self._datatype)}" + ) + + return AttrSetter(self, fn) + + def declare_setter_on( + self, owner: type, fn: UnboundSetter[Controller_T, DType_T] + ) -> None: + """Carry a setter into this declaration, on one `Controller` class. + + Called by an `AttrSetter` when the class it was declared in is created. + The declaration carrying the setter replaces this one in ``owner``'s + own namespace. The getter declaration must also be declared on + ``owner``; an inherited getter cannot be given a setter this way. + + Args: + owner: The `Controller` class the setter was declared in + fn: The setter, taking ``self`` and the value to apply + + Raises: + TypeError: If ``owner`` has no matching read-write declaration, or + the attribute already has a setter + + """ + declared = owner.__dict__.get(self._name) + if not isinstance(declared, UnboundAttrRW): + raise TypeError( + f"Cannot add setter for '{self._name}' to {owner.__name__}: " + "the read-write declaration is not defined on that class" + ) + + if declared.has_setter(): + raise TypeError( + f"Declared getter {self._getter.__qualname__} already has a setter" + ) + + declaration = UnboundAttrRW( + self._getter, + schedule=self._schedule, + meta=cast(Meta, self._meta), + setter=fn, + name=self._name, + ) + + setattr(owner, self._name, declaration) + def bind(self, controller: Controller_T) -> AttrRW[DType_T]: + if self._setter is None: + raise TypeError( + f"Attribute '{self._name}' was declared with AttrRW.declare but " + "has no setter. Add one with " + f"`@{self._name}.setter`, or declare it read-only with " + "AttrR.declare." + ) + return cast(AttrRW[DType_T], super().bind(controller)) -@overload -def attr( - getter: UnboundGetter[Controller_T, DType_T], / -) -> UnboundAttr[Controller_T, DType_T]: ... +class AttrSetter(Generic[Controller_T, DType_T]): + """The writer half of an ``AttrRW.declare``, given by ``@.setter``. + + The decorated method keeps a name of its own in the class body - + ``set_voltage`` for a ``voltage`` attribute, the way PyTango writes + ``write_voltage`` - rather than redeclaring the getter's name. When the + class is created this replaces the getter's declaration with a read-write + one, so ``voltage`` binds an ``AttrRW`` while ``set_voltage`` stays + callable as an ordinary method of the controller. + + Replacing the declaration is done on the class that declared the setter, so + a subclass writing ``@Base.voltage.setter`` leaves ``Base`` read-only. + """ + + def __init__( + self, + declaration: UnboundAttrRW[Controller_T, DType_T], + fn: UnboundSetter[Controller_T, DType_T], + ) -> None: + self._declaration = declaration + self._fn = fn + self.__doc__ = fn.__doc__ + def __set_name__(self, owner: type, name: str) -> None: + self._declaration.declare_setter_on(owner, self._fn) -@overload -def attr( - schedule: Schedule[Any] | None = None, /, **meta: Unpack[Meta] -) -> Callable[ - [UnboundGetter[Controller_T, DType_T]], UnboundAttr[Controller_T, DType_T] -]: ... + @overload + def __get__( + self, instance: None, owner: type | None = None, / + ) -> AttrSetter[Controller_T, DType_T]: ... + @overload + def __get__( + self, instance: Controller_T, owner: type | None = None, / + ) -> Callable[[DType_T], Awaitable[None | DType_T | Update[DType_T]]]: ... -def attr(getter_or_schedule: Any = None, /, **meta: Any) -> Any: - """Declare an `Attribute` from the method that reads it. + def __get__(self, instance: Any, owner: type | None = None, /) -> Any: + if instance is None: + return self - The datatype is the getter's return annotation and the getter's docstring - is the attribute's description, so the common "one attribute, one device - call" case is a single decorated method:: + return MethodType(self._fn, instance) - class PowerSupply(Controller): - @attr(Polled(period=0.5), units="V") - async def voltage(self) -> float: - \"\"\"Output voltage.\"\"\" - return float(await self._conn.query("V?")) + def __repr__(self) -> str: + return ( + f"{type(self).__name__}({self._fn.__qualname__}, " + f"attribute={self._declaration.name!r})" + ) - @voltage.setter - async def voltage(self, value: float) -> None: - await self._conn.send(f"V={value}") - The optional leading positional argument is a schedule - the same - `Polled`/`NotPolled` objects the procedural form wraps its getter in, so - the two spellings share one vocabulary: +def declare_attribute( + declaration_type: type[UnboundAttr[Any, Any]], + getter_or_schedule: Any, + meta: dict[str, Any], +) -> Any: + """Build what ``AttrR.declare``/``AttrRW.declare`` return. - - ``@attr(units="V")`` is read once, when the controller connects, which is - what a bare ``getter=`` means and what a bare ``@attr`` means - - ``@attr(Polled(period=0.5))`` is read every 0.5 seconds, as - ``AttrR(getter=Polled(g, period=0.5))`` is - - ``@attr(NotPolled())`` is never read on a schedule, as - ``AttrR(getter=NotPolled(g))`` is + Both spellings are the same decorator over a different declaration class, + and both take either a getter (used bare, ``@AttrR.declare``) or a schedule + and metadata (``@AttrR.declare(Polled(period=0.5), units="V")``). Args: - getter_or_schedule: The getter, when used bare as ``@attr``; otherwise - a `Polled` or `NotPolled` schedule, or nothing + declaration_type: `UnboundAttr` for a read-only declaration, + `UnboundAttrRW` for a read-write one + getter_or_schedule: The getter, when the decorator is used bare; + otherwise a `Polled` or `NotPolled` schedule, or nothing meta: Metadata for the attribute, checked against the datatype the - getter returns - ``precision`` on a ``str`` attribute raises + getter returns when it is bound Returns: - An `UnboundAttr`, which each `Controller` instance binds into an - attribute of its own + The declaration itself for the bare form, and the decorator that makes + one for the parameterised form """ if getter_or_schedule is not None and not isinstance( getter_or_schedule, Polled | NotPolled ): - # Bare ``@attr``, so what we have is the getter itself. There is no way - # to pass metadata in that form, so there is none to carry over. - return UnboundAttr(getter_or_schedule) + # Used bare, so what we have is the getter itself. There is no way to + # pass metadata in that form, so there is none to carry over. + return declaration_type(getter_or_schedule) - def wrapper( - getter: UnboundGetter[Controller_T, DType_T], - ) -> UnboundAttr[Controller_T, DType_T]: - return UnboundAttr(getter, schedule=getter_or_schedule, meta=cast(Meta, meta)) + def wrapper(getter: Any) -> Any: + return declaration_type( + getter, schedule=getter_or_schedule, meta=cast(Meta, meta) + ) return wrapper diff --git a/src/fastcs/attributes/attr_r.py b/src/fastcs/attributes/attr_r.py index b36b6c3a..ce03435b 100644 --- a/src/fastcs/attributes/attr_r.py +++ b/src/fastcs/attributes/attr_r.py @@ -4,7 +4,7 @@ import time from collections.abc import Awaitable, Callable, Coroutine from dataclasses import KW_ONLY, dataclass, replace -from typing import Any, Generic, Unpack, overload +from typing import TYPE_CHECKING, Any, Generic, Unpack, overload from fastcs.attributes._infer_datatype import infer_datatype_from_getter from fastcs.attributes.attribute import Attribute, AttributeAccessMode @@ -15,6 +15,7 @@ Array1DMeta, Array_T, BoolMeta, + Declared_T, DType_T, Enum_T, EnumMeta, @@ -27,10 +28,15 @@ TableMeta, ) from fastcs.logging import logger -from fastcs.util import ONCE +from fastcs.util import ONCE, Controller_T + +if TYPE_CHECKING: + from fastcs.attributes.attr_decorator import UnboundAttr Getter = Callable[[], Awaitable[DType_T | Update[DType_T]]] """A callable that fetches a fresh value for an attribute from its source""" +UnboundGetter = Callable[[Controller_T], Awaitable[DType_T | Update[DType_T]]] +"""A declared getter, taking the `Controller` it will be bound to as ``self``""" AttrReadbackCallback = Callable[[DType_T], Coroutine[None, None, None]] """A callback to be called when the readback of the attribute updates""" @@ -181,7 +187,7 @@ def __init__( resolved_getter, poll_period = None, None case _: # A getter with no schedule is read once, when the controller - # connects - the safe default, and what a bare ``@attr`` means. + # connects - the safe default, and what a bare declaration means. resolved_getter, poll_period = getter, ONCE if datatype is None and resolved_getter is not None: @@ -209,6 +215,68 @@ def __init__( self._on_update_events: set[PredicateEvent[DType_T]] = set() """Events to set when the value satisifies some predicate""" + @staticmethod + @overload + def declare( + getter: UnboundGetter[Controller_T, Declared_T], / + ) -> UnboundAttr[Controller_T, Declared_T]: ... + + @staticmethod + @overload + def declare( + schedule: Schedule[Any] | None = None, /, **meta: Unpack[Meta] + ) -> Callable[ + [UnboundGetter[Controller_T, Declared_T]], + UnboundAttr[Controller_T, Declared_T], + ]: ... + + @staticmethod + def declare(getter_or_schedule: Any = None, /, **meta: Any) -> Any: + """Declare a read-only attribute from the method that reads it. + + The datatype is the getter's return annotation and the getter's + docstring is the attribute's description, so the common "one attribute, + one device call" case is a single decorated method:: + + class PowerSupply(Controller): + @AttrR.declare(Polled(period=0.5), units="V") + async def voltage(self) -> float: + \"\"\"Output voltage.\"\"\" + return float(await self._conn.query("V?")) + + A getter declared this way is read-only and stays that way; declare it + with `AttrRW.declare` if the device can be written to. + + The optional leading positional argument is a schedule - the same + `Polled`/`NotPolled` objects the procedural form wraps its getter in, + so the two spellings share one vocabulary: + + - ``@AttrR.declare(units="V")`` is read once, when the controller + connects, which is what a bare ``getter=`` means and what a bare + ``@AttrR.declare`` means + - ``@AttrR.declare(Polled(period=0.5))`` is read every 0.5 seconds, as + ``AttrR(getter=Polled(g, period=0.5))`` is + - ``@AttrR.declare(NotPolled())`` is never read on a schedule, as + ``AttrR(getter=NotPolled(g))`` is + + Args: + getter_or_schedule: The getter, when used bare as + ``@AttrR.declare``; otherwise a `Polled` or `NotPolled` + schedule, or nothing + meta: Metadata for the attribute, checked against the datatype the + getter returns - ``precision`` on a ``str`` attribute raises + + Returns: + An `UnboundAttr`, which each `Controller` instance binds into an + attribute of its own + + """ + # Imported here because the decorator builds an ``AttrR``, so the + # module defining it imports this one. + from fastcs.attributes.attr_decorator import UnboundAttr, declare_attribute + + return declare_attribute(UnboundAttr, getter_or_schedule, meta) + @property def readback(self) -> DType_T: """The last known value of the attribute.""" diff --git a/src/fastcs/attributes/attr_rw.py b/src/fastcs/attributes/attr_rw.py index 90222c78..5134b8bd 100644 --- a/src/fastcs/attributes/attr_rw.py +++ b/src/fastcs/attributes/attr_rw.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import Any, Unpack, overload +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Unpack, overload -from fastcs.attributes.attr_r import AttrR, Getter, Schedule +from fastcs.attributes.attr_r import AttrR, Getter, Schedule, UnboundGetter from fastcs.attributes.attr_w import AttrW, Setter from fastcs.attributes.attribute import AttributeAccessMode from fastcs.attributes.update import Update @@ -10,6 +11,7 @@ Array1DMeta, Array_T, BoolMeta, + Declared_T, DType_T, Enum_T, EnumMeta, @@ -22,6 +24,10 @@ TableMeta, ) from fastcs.logging import logger +from fastcs.util import Controller_T + +if TYPE_CHECKING: + from fastcs.attributes.attr_decorator import UnboundAttrRW class AttrRW(AttrR[DType_T], AttrW[DType_T]): @@ -139,6 +145,67 @@ def __init__( **meta, ) + @staticmethod + @overload + def declare( + getter: UnboundGetter[Controller_T, Declared_T], / + ) -> UnboundAttrRW[Controller_T, Declared_T]: ... + + @staticmethod + @overload + def declare( + schedule: Schedule[Any] | None = None, /, **meta: Unpack[Meta] + ) -> Callable[ + [UnboundGetter[Controller_T, Declared_T]], + UnboundAttrRW[Controller_T, Declared_T], + ]: ... + + @staticmethod + def declare(getter_or_schedule: Any = None, /, **meta: Any) -> Any: + """Declare a read-write attribute from the method that reads it. + + The read-write counterpart of `AttrR.declare`, and the same in every + respect but one: the attribute it declares expects a writer half, given + by a ``@x.setter`` method:: + + class PowerSupply(Controller): + @AttrRW.declare(Polled(period=0.5), units="V") + async def voltage(self) -> float: + \"\"\"Output voltage.\"\"\" + return float(await self._conn.query("V?")) + + @voltage.setter + async def set_voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") + + The setter has a name of its own, as PyTango's ``write_voltage`` does, + and stays callable as an ordinary method: ``await + self.set_voltage(5.0)`` writes to the device, while ``await + self.voltage.set(5.0)`` writes through the attribute and so updates + what clients see. + + Args: + getter_or_schedule: The getter, when used bare as + ``@AttrRW.declare``; otherwise a `Polled` or `NotPolled` + schedule, or nothing + meta: Metadata for the attribute, checked against the datatype the + getter returns - ``precision`` on a ``str`` attribute raises + + Returns: + An `UnboundAttrRW`, which each `Controller` instance binds into an + attribute of its own. Binding a declaration that was never given a + setter raises, naming the attribute. + + """ + # Imported here because the decorator builds an ``AttrRW``, so the + # module defining it imports this one. + from fastcs.attributes.attr_decorator import ( + UnboundAttrRW, + declare_attribute, + ) + + return declare_attribute(UnboundAttrRW, getter_or_schedule, meta) + @property def access_mode(self) -> AttributeAccessMode: return "rw" diff --git a/src/fastcs/attributes/attr_w.py b/src/fastcs/attributes/attr_w.py index ca7e06c8..cd12ea4b 100644 --- a/src/fastcs/attributes/attr_w.py +++ b/src/fastcs/attributes/attr_w.py @@ -23,9 +23,14 @@ TableMeta, ) from fastcs.logging import logger +from fastcs.util import Controller_T Setter = Callable[[DType_T], Awaitable[None | DType_T | Update[DType_T]]] """A callable that applies a new setpoint to an attribute's source""" +UnboundSetter = Callable[ + [Controller_T, DType_T], Awaitable[None | DType_T | Update[DType_T]] +] +"""An ``@x.setter`` setter, taking the `Controller` it will be bound to as ``self``""" AttrSetpointCallback = Callable[[DType_T], Coroutine[None, None, None]] """A callback to be called when the setpoint of the attribute updates""" diff --git a/src/fastcs/controllers/base_controller.py b/src/fastcs/controllers/base_controller.py index ef3b7625..cf3d2e9a 100755 --- a/src/fastcs/controllers/base_controller.py +++ b/src/fastcs/controllers/base_controller.py @@ -118,9 +118,10 @@ def _bind_attrs(self) -> None: controller class. For Methods, this requires creating a bound method from a class method and a controller instance, so that it can be called from any context with the controller instance passed as the ``self`` argument. - An ``@attr``-decorated getter is an `UnboundAttr` declaration rather than - an Attribute, and is bound the same way the Methods are - into a fresh - Attribute whose getter and setter are methods of this instance. + An ``AttrR.declare``/``AttrRW.declare``-decorated getter is an + `UnboundAttr` declaration rather than an Attribute, and is bound the + same way the Methods are - into a fresh Attribute whose getter and + setter are methods of this instance. """ class_dir = dict.fromkeys(self._walk_mro()) diff --git a/src/fastcs/datatypes/__init__.py b/src/fastcs/datatypes/__init__.py index 8cbe51bd..e039219a 100644 --- a/src/fastcs/datatypes/__init__.py +++ b/src/fastcs/datatypes/__init__.py @@ -14,6 +14,7 @@ from .meta import TableMeta as TableMeta from .types import Array1D as Array1D from .types import Array_T as Array_T +from .types import Declared_T as Declared_T from .types import DType as DType from .types import DType_T as DType_T from .types import Enum_T as Enum_T diff --git a/src/fastcs/datatypes/types.py b/src/fastcs/datatypes/types.py index bea3b7bb..e1571db8 100644 --- a/src/fastcs/datatypes/types.py +++ b/src/fastcs/datatypes/types.py @@ -44,6 +44,14 @@ be used there. """ +Declared_T = TypeVar("Declared_T", bound=DType) +"""A TypeVar of `DType` for the ``AttrR.declare``/``AttrRW.declare`` decorators + +Distinct from `DType_T` for the same reason as `Inferred_T`: ``declare`` is a +static method of a generic class, so the class's own TypeVar would leave the +declared datatype unsolved rather than binding it from the decorated getter. +""" + Array1D: TypeAlias = np.ndarray[tuple[int], np.dtype[NumpyScalar_T]] """A one dimensional numpy array, subscripted with its element type. diff --git a/src/fastcs/demo/README.md b/src/fastcs/demo/README.md index 3865e7a2..b6e5db9d 100644 --- a/src/fastcs/demo/README.md +++ b/src/fastcs/demo/README.md @@ -18,12 +18,13 @@ examples are the docs. Two hardware backends: a temperature-controller sim and a cut-down Eiger REST sim. The hello-world is pure-soft (no backend). IO is supplied as plain -`getter`/`setter` callables on `AttrR`/`AttrW`/`AttrRW` (or the `@attr` -decorator) — there is no `io=` object and no `DataType`. +`getter`/`setter` callables on `AttrR`/`AttrW`/`AttrRW` (or the +`AttrR.declare`/`AttrRW.declare` decorators) — there is no `io=` object and no +`DataType`. | Module | Concept | Backend | Issue | |--------|---------|---------|-------| -| `hello_world.py` | pure-soft `@attr` decorator over in-memory values | none (soft) | [#398](https://github.com/DiamondLightSource/fastcs/issues/398) | +| `hello_world.py` | pure-soft `AttrR.declare`/`AttrRW.declare` decorators over in-memory values | none (soft) | [#398](https://github.com/DiamondLightSource/fastcs/issues/398) | | `temperature_attr.py` | `getter`/`setter` callables in `__init__` (`AttrRW(getter=…, setter=…)`), then composition & methods: sub-controllers / `ControllerVector`, `@scan`, `@command` | temperature sim | [#404](https://github.com/DiamondLightSource/fastcs/issues/404), [#390](https://github.com/DiamondLightSource/fastcs/issues/390) | | `temperature_scpi.py` (+ `scpi.py`) | declarative annotated attributes; `ControllerFiller` builds each getter/setter from **static** `SCPIParam` extras metadata | temperature sim | [#405](https://github.com/DiamondLightSource/fastcs/issues/405) | | `eiger.py` (+ `simulation/eiger.py`) | introspectable device: bare hints filled from a **runtime** REST parameter tree | Eiger REST sim | [#391](https://github.com/DiamondLightSource/fastcs/issues/391) | @@ -34,7 +35,7 @@ Four modules, **four** tutorials (the old "reusable `io=` object" rung is gone `io=` objects were replaced by getter/setter callables, so there is nothing to factor into): -1. **hello world** — `hello_world.py` (soft `@attr`). +1. **hello world** — `hello_world.py` (soft `AttrR.declare`/`AttrRW.declare`). 2. **getter/setter** — `temperature_attr.py`; the full multi-ramp temperature controller, so this is also where **composition + `@scan` + `@command`** are shown (#390). Closes with *"when the shared pattern is worth naming, @@ -68,8 +69,8 @@ Notes: `temperature_attr.py` and `eiger.py` have current-API baselines that can be written **now** (deliberately messy against the pre-refactor API) and are cleaned up as each framework PR lands. `hello_world.py` -and `temperature_scpi.py` need framework work first (`@attr` #397; -`ControllerFiller` #394). See each issue's `Blocked by:` line. +followed the decorator (#397) landing; `temperature_scpi.py` still waits on +`ControllerFiller` (#394). See each issue's `Blocked by:` line. `literalinclude` region markers are added to each module as part of writing its tutorial (the umbrella docs pass, diff --git a/src/fastcs/demo/hello_world.py b/src/fastcs/demo/hello_world.py new file mode 100644 index 00000000..c6a5114d --- /dev/null +++ b/src/fastcs/demo/hello_world.py @@ -0,0 +1,87 @@ +"""Example 1 - hello world: a controller made entirely of soft values. + +The first rung of the ladder, and the only one with no device behind it. Every +value here lives in the controller object itself, 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. + +An attribute is the method that reads it:: + + @AttrR.declare + async def uptime(self) -> float: + \"\"\"Seconds since the controller was constructed.\"\"\" + return time.monotonic() - self._started + +That single decorated method is a read-only ``AttrR[float]``: the datatype is +the return annotation, and the docstring's first line becomes the description a +transport shows next to the value. A value the device can be told as well as +asked is declared with ``AttrRW.declare`` and a ``@x.setter`` method:: + + @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 = value + +The decorator names the class it builds, so ``greeting`` is an ``AttrRW[str]`` +to a reader and to a type checker alike. The setter has a name of its own, as +PyTango's ``write_greeting`` does, so the two halves of one attribute are never +two declarations of one name. + +The decorator's optional leading argument is a schedule, and its keyword +arguments are the attribute's metadata - so ``@AttrR.declare(Polled(period=0.2), +units="s")`` is a value read every 0.2 seconds, served in seconds. Both are the +same vocabulary the procedural ``AttrR(float, getter=Polled(...), units="s")`` +form uses; the decorator is sugar over those constructors rather than a second +way of doing it. + +Where to go next: ``fastcs.demo.temperature_attr`` wires the same attributes to a +real device by passing bound protocol methods to ``AttrRW(getter=..., +setter=...)``, which is what you want as soon as there is IO to do. +""" + +import time + +from fastcs.attributes import AttrR, AttrRW, Polled +from fastcs.controllers import Controller + + +class HelloWorldController(Controller): + """A greeting, the message it makes, and how long it has been running. + + Nothing in here does any IO. ``message`` recomputes from the current + ``greeting`` each time it is polled, so setting ``greeting`` visibly moves + a second value - the same thing a real device does when one parameter + depends on another, without needing a device to demonstrate it. + """ + + def __init__(self, subject: str = "world") -> None: + super().__init__() + + self._greeting = "Hello" + self._subject = subject + self._started = time.monotonic() + + @AttrRW.declare + async def greeting(self) -> str: + """The word to greet with.""" + # A bare declaration 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 + + @AttrR.declare(Polled(period=0.2)) + async def message(self) -> str: + """The greeting as it currently reads.""" + return f"{self._greeting}, {self._subject}!" + + @AttrR.declare(Polled(period=0.2), units="s", precision=1) + async def uptime(self) -> float: + """Seconds since the controller was constructed.""" + return time.monotonic() - self._started diff --git a/tests/demo/test_hello_world.py b/tests/demo/test_hello_world.py new file mode 100644 index 00000000..7ec24054 --- /dev/null +++ b/tests/demo/test_hello_world.py @@ -0,0 +1,90 @@ +from typing import assert_type + +import pytest + +from fastcs.attributes import AttrR, AttrRW +from fastcs.demo.hello_world import HelloWorldController +from fastcs.util import ONCE + + +@pytest.fixture +def controller() -> HelloWorldController: + return HelloWorldController() + + +def test_greeting_is_read_write(controller: HelloWorldController): + assert isinstance(controller.greeting, AttrRW) + assert controller.greeting.dtype is str + + +def test_the_declared_class_is_the_static_type(controller: HelloWorldController): + # `assert_type` is checked by pyright rather than at runtime, which is the + # point: naming the class in the decorator is what makes + # `controller.greeting.set(...)` need no narrowing at its use sites. + assert_type(controller.greeting, AttrRW[str]) + assert_type(controller.message, AttrR[str]) + assert_type(controller.uptime, AttrR[float]) + + +def test_derived_attributes_are_read_only(controller: HelloWorldController): + assert isinstance(controller.message, AttrR) + assert not isinstance(controller.message, AttrRW) + assert isinstance(controller.uptime, AttrR) + assert not isinstance(controller.uptime, AttrRW) + + +def test_docstrings_become_descriptions(controller: HelloWorldController): + assert controller.greeting.description == "The word to greet with." + assert controller.message.description == "The greeting as it currently reads." + assert ( + controller.uptime.description == "Seconds since the controller was constructed." + ) + + +def test_schedules(controller: HelloWorldController): + # A bare declaration is read once, on connect; the derived values are polled. + assert controller.greeting.poll_period == ONCE + assert controller.message.poll_period == 0.2 + assert controller.uptime.poll_period == 0.2 + + +def test_decorator_keywords_are_metadata(controller: HelloWorldController): + assert controller.uptime.dtype is float + assert controller.uptime.meta == { + "units": "s", + "precision": 1, + "description": "Seconds since the controller was constructed.", + } + + +@pytest.mark.asyncio +async def test_message_follows_the_greeting(controller: HelloWorldController): + assert await controller.message.poll() == "Hello, world!" + + await controller.greeting.set("Goodbye") + + assert controller.greeting.setpoint == "Goodbye" + assert await controller.greeting.poll() == "Goodbye" + assert await controller.message.poll() == "Goodbye, world!" + + +@pytest.mark.asyncio +async def test_subject_is_a_constructor_argument(): + controller = HelloWorldController("beamline") + + assert await controller.message.poll() == "Hello, beamline!" + + +@pytest.mark.asyncio +async def test_uptime_advances(controller: HelloWorldController): + first = await controller.uptime.poll() + second = await controller.uptime.poll() + + assert second >= first + + +@pytest.mark.asyncio +async def test_the_setter_is_also_an_ordinary_method(controller: HelloWorldController): + await controller.set_greeting("Goodbye") + + assert await controller.message.poll() == "Goodbye, world!" diff --git a/tests/test_attr_decorator.py b/tests/test_attr_decorator.py index 711cca1c..abf71149 100644 --- a/tests/test_attr_decorator.py +++ b/tests/test_attr_decorator.py @@ -7,11 +7,12 @@ from fastcs.attributes import ( AttrR, AttrRW, + AttrSetter, NotPolled, Polled, UnboundAttr, + UnboundAttrRW, Update, - attr, ) from fastcs.controllers import Controller from fastcs.datatypes import Array1D, Limits, NumericLimits @@ -24,7 +25,7 @@ class State(Enum): class PowerSupply(Controller): - """A controller declaring its attributes with ``@attr``.""" + """A controller declaring its attributes with the decorators.""" def __init__(self) -> None: super().__init__() @@ -32,8 +33,8 @@ def __init__(self) -> None: self.sent: list[float] = [] self._voltage = 1.5 - @attr(Polled(period=0.5), units="V", precision=3) - async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + @AttrRW.declare(Polled(period=0.5), units="V", precision=3) + async def voltage(self) -> float: """Output voltage. The rest of the docstring says more than a description should. @@ -41,22 +42,27 @@ async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] return self._voltage @voltage.setter - async def voltage(self, value: float) -> None: + async def set_voltage(self, value: float) -> None: self.sent.append(value) self._voltage = value - @attr + @AttrR.declare async def serial(self) -> str: """Serial number.""" return "PSU-1" - @attr(NotPolled(), group="Config") + @AttrR.declare(NotPolled(), group="Config") async def retries(self) -> int: return 3 -def test_getter_only_is_read_only(): - controller = PowerSupply() +@pytest.fixture +def power_supply() -> PowerSupply: + return PowerSupply() + + +def test_getter_only_is_read_only(power_supply: PowerSupply): + controller = power_supply assert isinstance(controller.serial, AttrR) assert not isinstance(controller.serial, AttrRW) @@ -64,24 +70,24 @@ def test_getter_only_is_read_only(): assert controller.serial.access_mode == "r" -def test_getter_and_setter_is_read_write(): - controller = PowerSupply() +def test_getter_and_setter_is_read_write(power_supply: PowerSupply): + controller = power_supply assert isinstance(controller.voltage, AttrRW) assert controller.voltage.dtype is float assert controller.voltage.access_mode == "rw" -def test_attributes_are_registered_with_the_controller(): - controller = PowerSupply() +def test_attributes_are_registered_with_the_controller(power_supply: PowerSupply): + controller = power_supply assert list(controller.attributes) == ["voltage", "serial", "retries"] assert controller.attributes["voltage"] is controller.voltage assert controller.voltage.name == "voltage" -def test_metadata_from_decorator(): - controller = PowerSupply() +def test_metadata_from_decorator(power_supply: PowerSupply): + controller = power_supply assert controller.voltage.meta == { "units": "V", @@ -92,8 +98,8 @@ def test_metadata_from_decorator(): assert controller.retries.group == "Config" -def test_docstring_summary_becomes_the_description(): - controller = PowerSupply() +def test_docstring_summary_becomes_the_description(power_supply: PowerSupply): + controller = power_supply # Only the first paragraph - a description is a one-line label. assert controller.voltage.description == "Output voltage." @@ -103,7 +109,7 @@ def test_docstring_summary_becomes_the_description(): def test_explicit_description_wins_over_the_docstring(): class Device(Controller): - @attr(description="From the decorator") + @AttrR.declare(description="From the decorator") async def label(self) -> str: """From the docstring.""" return "x" @@ -111,11 +117,11 @@ async def label(self) -> str: assert Device().label.description == "From the decorator" -def test_schedules(): - controller = PowerSupply() +def test_schedules(power_supply: PowerSupply): + controller = power_supply assert controller.voltage.poll_period == 0.5 - # A bare ``@attr`` means what a bare ``getter=`` means - read once, at connect. + # A bare declaration means what a bare ``getter=`` means - read once, at connect. assert controller.serial.poll_period is ONCE assert controller.retries.poll_period is None assert controller.retries.has_getter() @@ -133,6 +139,7 @@ async def test_bound_getter_reads_from_its_own_instance(): @pytest.mark.asyncio async def test_bound_setter_writes_to_its_own_instance(): one, two = PowerSupply(), PowerSupply() + assert isinstance(one.voltage, AttrRW) await one.voltage.set(2.5) @@ -158,17 +165,40 @@ def test_class_body_holds_the_declaration(): assert "access_mode='rw'" in repr(PowerSupply.voltage) +def test_the_declaration_names_the_class_it_will_build(): + assert isinstance(PowerSupply.voltage, UnboundAttrRW) + assert not isinstance(PowerSupply.serial, UnboundAttrRW) + + +def test_a_read_only_declaration_cannot_be_given_a_setter(): + with pytest.raises(AttributeError, match="has no attribute 'setter'"): + + @PowerSupply.serial.setter # pyright: ignore[reportAttributeAccessIssue] + async def set_serial(self, value: str) -> None: + pass + + +def test_a_read_write_declaration_without_a_setter_raises(): + class Device(Controller): + @AttrRW.declare + async def voltage(self) -> float: + return 0.0 + + with pytest.raises(TypeError, match="declared with AttrRW.declare but has no set"): + Device() + + def test_datatype_inferred_from_the_return_annotation(): class Device(Controller): - @attr + @AttrR.declare async def flag(self) -> bool: return True - @attr + @AttrR.declare async def state(self) -> State: return State.IDLE - @attr(shape=(4,)) + @AttrR.declare(shape=(4,)) async def trace(self) -> Array1D[np.int32]: return np.zeros(4, dtype=np.int32) @@ -180,10 +210,23 @@ async def trace(self) -> Array1D[np.int32]: assert controller.trace.meta == {"array_dtype": np.int32, "shape": (4,)} +def test_matching_array_datatypes_are_allowed_on_getter_and_setter(): + class Device(Controller): + @AttrRW.declare + async def trace(self) -> Array1D[np.int32]: + return np.zeros(4, dtype=np.int32) + + @trace.setter + async def set_trace(self, value: Array1D[np.int32]) -> None: + pass + + assert Device.trace.has_setter() + + @pytest.mark.asyncio async def test_update_return_annotation_is_unwrapped(): class Device(Controller): - @attr + @AttrR.declare async def temperature(self) -> Update[float]: return Update(readback=20.5, timestamp=1000.0) @@ -196,7 +239,7 @@ async def temperature(self) -> Update[float]: def test_metadata_is_validated_against_the_inferred_datatype(): class Device(Controller): - @attr(precision=3) + @AttrR.declare(precision=3) async def label(self) -> str: return "x" @@ -206,7 +249,7 @@ async def label(self) -> str: def test_limits_metadata(): class Device(Controller): - @attr(limits=NumericLimits(control=Limits(0.0, 10.0))) + @AttrR.declare(limits=NumericLimits(control=Limits(0.0, 10.0))) async def setpoint(self) -> float: return 1.0 @@ -219,7 +262,7 @@ def test_matching_type_hint_is_satisfied_by_the_decorated_attribute(): class Device(Controller): label: AttrR[str] # pyright: ignore[reportRedeclaration] - @attr + @AttrR.declare async def label(self) -> str: return "x" @@ -232,7 +275,7 @@ def test_type_hint_of_the_wrong_access_mode_raises(): class Device(Controller): label: AttrRW[str] # pyright: ignore[reportRedeclaration] - @attr + @AttrR.declare async def label(self) -> str: return "x" @@ -247,7 +290,7 @@ def __init__(self) -> None: self.label = AttrR(str) # pyright: ignore[reportAttributeAccessIssue] - @attr + @AttrR.declare async def label(self) -> str: return "x" @@ -260,7 +303,7 @@ async def label(self) -> str: def test_getter_must_be_async(): with pytest.raises(TypeError, match="getter .* must be an async function"): - @attr() # pyright: ignore[reportArgumentType] + @AttrR.declare() # pyright: ignore[reportArgumentType] def voltage(self) -> float: return 0.0 @@ -268,7 +311,7 @@ def voltage(self) -> float: def test_getter_must_take_only_self(): with pytest.raises(TypeError, match="getter .* must be a method taking self"): - @attr() # pyright: ignore[reportArgumentType] + @AttrR.declare() # pyright: ignore[reportArgumentType] async def voltage(self, index: int) -> float: return 0.0 @@ -276,7 +319,7 @@ async def voltage(self, index: int) -> float: def test_getter_must_annotate_its_return_type(): with pytest.raises(TypeError, match="must annotate the datatype"): - @attr() + @AttrR.declare() async def voltage(self): return 0.0 @@ -284,26 +327,26 @@ async def voltage(self): def test_getter_must_return_a_supported_datatype(): with pytest.raises(TypeError, match="must annotate a supported datatype"): - @attr() # pyright: ignore[reportArgumentType] + @AttrR.declare() # pyright: ignore[reportArgumentType] async def voltage(self) -> list[int]: return [] def test_setter_must_be_async(): - @attr - async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + @AttrRW.declare + async def voltage(self) -> float: return 0.0 with pytest.raises(TypeError, match="setter .* must be an async function"): @voltage.setter # pyright: ignore[reportArgumentType] - def voltage(self, value: float) -> None: + def set_voltage(self, value: float) -> None: pass def test_setter_must_take_a_value(): - @attr - async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + @AttrRW.declare + async def voltage(self) -> float: return 0.0 with pytest.raises( @@ -311,65 +354,72 @@ async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] ): @voltage.setter # pyright: ignore[reportArgumentType] - async def voltage(self) -> None: + async def set_voltage(self) -> None: pass def test_setter_value_must_match_the_getter_datatype(): - @attr - async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + @AttrRW.declare + async def voltage(self) -> float: return 0.0 with pytest.raises(TypeError, match="takes a str, but its getter returns a float"): @voltage.setter # pyright: ignore[reportArgumentType] - async def voltage(self, value: str) -> None: + async def set_voltage(self, value: str) -> None: pass def test_setter_value_annotation_is_optional(): - @attr - async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] - return 0.0 + class Device(Controller): + @AttrRW.declare + async def voltage(self) -> float: + return 0.0 - @voltage.setter - async def voltage(self, value) -> None: - pass + @voltage.setter + async def set_voltage(self, value) -> None: + pass - assert voltage.has_setter() + assert Device.voltage.has_setter() -def test_only_one_setter(): - @attr - async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] - return 0.0 +@pytest.mark.asyncio +async def test_the_setter_is_still_a_method_of_the_controller( + power_supply: PowerSupply, +): + controller = power_supply - @voltage.setter - async def voltage(self, value: float) -> None: - pass + assert isinstance(PowerSupply.set_voltage, AttrSetter) + + await controller.set_voltage(2.5) + + assert controller.sent == [2.5] + +def test_only_one_setter(): with pytest.raises(TypeError, match="already has a setter"): - @voltage.setter - async def voltage(self, value: float) -> None: + @PowerSupply.voltage.setter + async def write_voltage(self, value: float) -> None: pass -def test_setter_does_not_leak_onto_the_class_it_was_inherited_from(): +def test_a_setter_requires_a_declaration_on_the_same_class(): class Base(Controller): - @attr + @AttrRW.declare async def voltage(self) -> float: return 0.0 - class Child(Base): - @Base.voltage.setter # pyright: ignore[reportArgumentType] - async def voltage(self, value: float) -> None: - pass + with pytest.raises(Exception) as exc_info: + + class Child(Base): + @Base.voltage.setter # pyright: ignore[reportArgumentType] + async def set_voltage(self, value: float) -> None: + pass - assert not Base.voltage.has_setter() - assert Child.voltage.has_setter() - assert not isinstance(Base().voltage, AttrRW) - assert isinstance(Child().voltage, AttrRW) + error = exc_info.value.__cause__ or exc_info.value + assert isinstance(error, TypeError) + assert "read-write declaration is not defined on that class" in str(error) def test_schedule_must_not_already_have_a_getter(): @@ -378,14 +428,14 @@ async def read() -> float: with pytest.raises(TypeError, match="already has a getter"): - @attr(Polled(read, period=0.1)) + @AttrR.declare(Polled(read, period=0.1)) async def voltage(self) -> float: return 0.0 @pytest.mark.asyncio -async def test_polled_attributes_are_scheduled(): - controller = PowerSupply() +async def test_polled_attributes_are_scheduled(power_supply: PowerSupply): + controller = power_supply _, periodic, initial = controller.create_api_and_tasks() # ``serial`` is read once at connect; ``voltage`` is polled at 0.5s;