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
3 changes: 3 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@
("docutils", "fastcs.demo.temperature_attr.TemperatureControllerSettings"),
# TypeVar without docstrings still give warnings
("py:class", "strawberry.schema.schema.Schema"),
# 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"),
]
nitpick_ignore_regex = [
("py:class", r"fastcs.*.DType_T"),
Expand Down
7 changes: 7 additions & 0 deletions docs/explanations/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ A transport connects a `ControllerAPI` to an external protocol. The `ControllerA
- Scan methods (`@scan`)
- Sub-controller APIs (hierarchical structure)

A command may take arguments and return a value, and not every protocol can
carry such a call. A transport reads `command.signature` (or the
`argument_types`/`return_datatype`/`is_void` shortcuts) and decides for itself:
serve it, or set `command.enabled = False` and log a warning saying why, so the
rest of the controller is still served. See
[](../how-to/typed-commands.md) for what each transport does.

## Implementing a Transport

Subclass `Transport` and implement `connect()` and `serve()`:
Expand Down
101 changes: 101 additions & 0 deletions docs/how-to/typed-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Give a Command Arguments and a Return Value

A `@command` may take positional arguments and give a value back. Both are
declared the ordinary way - by annotating the method - and both are optional and
independent, so a command can take arguments and return nothing, return
something and take nothing, or do both.

```python
from fastcs.controllers import Controller
from fastcs.methods import command

class Stage(Controller):
@command()
async def stop(self) -> None:
"""Void: no arguments, no return value."""
await self._protocol.stop()

@command()
async def move_to(self, position: float, wait: bool) -> None:
"""Two positional arguments."""
await self._protocol.move(position, wait)

@command()
async def measure(self) -> float:
"""A return value."""
return await self._protocol.read_position()
```

## What a command may take and return

Arguments and return values are `bool`, `int`, `float`, `str`, or an
`enum.Enum` subclass - the same python types an attribute holds, minus arrays
and tables. Everything must be annotated: a command's signature is what
transports read to decide how to expose it, so it has to be fully known.

```python
@command()
async def move_to(self, position): # TypeError: no type annotation
...

@command()
async def plot(self, trace: list[float]): # TypeError: unsupported type
...
```

Arguments are positional. Keyword-only arguments, `*args` and `**kwargs` are
rejected.

An array-valued command has an attribute-shaped alternative: write the array to
an `AttrW` and trigger a void command, rather than passing it as an argument.

## Which transports serve them

Not every protocol can carry a typed call, so each transport declares what it
can do rather than the framework assuming they are all alike. A command a
transport cannot serve is **skipped with a warning at start-up** - the rest of
the controller is still served.

| Transport | Void command | Arguments | Return value |
| --------- | ------------ | --------- | ------------ |
| REST | ✅ | ✅ any number, as a JSON body | ✅ as `{"value": …}` |
| GraphQL | ✅ | ✅ any number, as mutation arguments | ✅ the mutation result |
| Tango | ✅ | ⚠️ at most one, and not an enum | ⚠️ not an enum |
| EPICS CA | ✅ | ❌ | ❌ |
| EPICS PVA | ✅ | ❌ | ❌ |

The EPICS transports serve a command as a single "do it" PV. There is no PV
representation of "call with these arguments and give me this back" that is not
already a set of attributes, so a typed command has nothing to map onto. Tango
commands carry at most one input value, which is a limit of the protocol.

If a command must be reachable over EPICS, keep it void and put its arguments
and results on attributes:

```python
class Stage(Controller):
target = AttrRW(float)
last_position = AttrR(float)

@command()
async def move(self) -> None:
await self._protocol.move(self.target.setpoint)
await self.last_position.update(await self._protocol.read_position())
```

## Reading a command's signature

A transport - or anything else walking a `ControllerAPI` - gets the whole
picture from the command itself:

```python
command = controller_api.command_methods["move_to"]

command.signature # (position: float, wait: bool) -> None
command.argument_types # (float, bool)
command.return_datatype # None
command.is_void # False
```

`signature` is the bound signature, without `self`, so it is what a caller
would actually pass.
1 change: 1 addition & 0 deletions src/fastcs/methods/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .command import COMMAND_DTYPES as COMMAND_DTYPES
from .command import Command as Command
from .command import CommandCallback as CommandCallback
from .command import UnboundCommand as UnboundCommand
Expand Down
163 changes: 141 additions & 22 deletions src/fastcs/methods/command.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,163 @@
import enum
from collections.abc import Callable, Coroutine
from inspect import Parameter, Signature
from types import MethodType
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Concatenate, Generic, ParamSpec, TypeVar

from fastcs.datatypes import DType
from fastcs.logging import logger
from fastcs.methods.method import Controller_T, Method

if TYPE_CHECKING:
from fastcs.controllers import BaseController # noqa: F401


UnboundCommandCallback = Callable[[Controller_T], Coroutine[None, None, None]]
P = ParamSpec("P")
"""The parameters a `Command` takes"""
T = TypeVar("T")
"""The value a `Command` returns"""

UnboundCommandCallback = Callable[
Concatenate[Controller_T, P], Coroutine[None, None, T]
]
"""A Command callback that is unbound and must be called with a `Controller` instance"""
CommandCallback = Callable[[], Coroutine[None, None, None]]
CommandCallback = Callable[P, Coroutine[None, None, T]]
"""A Command callback that is bound and can be called without `self`"""


class Command(Method["BaseController"]):
COMMAND_DTYPES: tuple[type, ...] = (bool, int, float, str, enum.Enum)
"""The types a command argument or return value may have.

A subset of ``DType``: arrays and tables are deliberately left out. Serving them
would mean duplicating the array serialisation each transport already has for
attributes rather than sharing it, which ADR 0015 explicitly does not want, and
an array-valued command has an attribute-shaped alternative today.
"""


def _validate_datatype(annotation: Any, what: str, fn: Callable) -> type[DType]:
if annotation is Signature.empty:
raise TypeError(
f"{what} of command {fn.__qualname__} has no type annotation. A "
"command's argument and return types must be fully known"
)

if not (isinstance(annotation, type) and issubclass(annotation, COMMAND_DTYPES)):
raise TypeError(
f"{what} of command {fn.__qualname__} has unsupported type "
f"{annotation!r}. Commands take and return "
f"{', '.join(t.__name__ for t in COMMAND_DTYPES)}"
)

return annotation


def _validate_arguments(
signature: Signature, fn: Callable, *, skip: int
) -> tuple[type[DType], ...]:
"""Check a command's parameters and collect their types.

Args:
signature: The command's signature
fn: The wrapped function, to name it in errors
skip: Leading parameters that are not command arguments - one for an
unbound method, which still declares ``self``

Returns:
The type of each argument, in order

Raises:
TypeError: If a parameter is not a positional argument of a known type

"""
parameters = list(signature.parameters.values())[skip:]

argument_types = []
for parameter in parameters:
if parameter.kind is Parameter.KEYWORD_ONLY:
raise TypeError(
f"Command {fn.__qualname__} has keyword-only argument "
f"'{parameter.name}'. Command arguments are positional; "
"keyword arguments are not supported yet"
)
if parameter.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD):
raise TypeError(
f"Command {fn.__qualname__} takes *args or **kwargs. A "
"command's arguments must be fully known"
)

argument_types.append(
_validate_datatype(parameter.annotation, f"Argument '{parameter.name}'", fn)
)

return tuple(argument_types)


def _validate_return(signature: Signature, fn: Callable) -> type[DType] | None:
annotation = signature.return_annotation
if annotation in (None, Signature.empty):
return None

return _validate_datatype(annotation, "Return value", fn)


class Command(Method["BaseController"], Generic[P, T]):
"""A `Controller` `Method` that performs a single action when called.

A command may take positional arguments and return a value, both of known
types - ``Command[[float], None]`` moves to a position, ``Command[[], None]``
is the void case. What it takes and gives back is its ``signature``, which
is what a transport reads to decide how - or whether - to serve it.

This class contains a function that is bound to a specific `Controller` instance and
is callable outside of the class context, without an explicit `self` parameter.
Calling an instance of this class will call the bound `Controller` method.
"""

def __init__(self, fn: CommandCallback, *, group: str | None = None):
def __init__(self, fn: CommandCallback[P, T], *, group: str | None = None):
super().__init__(fn, group=group)

def _validate(self, fn: CommandCallback) -> None:
def _validate(self, fn: CommandCallback[P, T]) -> None:
super()._validate(fn)

if not len(self.parameters) == 0:
raise TypeError(f"Command method cannot have arguments: {fn}")
self._argument_types = _validate_arguments(self.signature, fn, skip=0)
self._return_datatype = _validate_return(self.signature, fn)

async def __call__(self):
return await self.fn()
@property
def argument_types(self) -> tuple[type[DType], ...]:
"""The type of each positional argument the command takes."""
return self._argument_types

@property
def fn(self) -> CommandCallback:
async def command():
def return_datatype(self) -> type[DType] | None:
"""The type the command returns, or ``None`` if it returns nothing."""
return self._return_datatype

@property
def is_void(self) -> bool:
"""Whether the command takes no arguments and returns nothing.

A void command can be served by any transport; a typed one needs a
protocol that can carry a typed call.
"""
return not self._argument_types and self._return_datatype is None

async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
return await self.fn(*args, **kwargs)

@property
def fn(self) -> CommandCallback[P, T]:
async def command(*args: P.args, **kwargs: P.kwargs) -> T:
try:
return await self._fn()
return await self._fn(*args, **kwargs)
except Exception:
logger.exception("Command failed", fn=self._fn)
raise

return command


class UnboundCommand(Method[Controller_T]):
class UnboundCommand(Method[Controller_T], Generic[Controller_T, P, T]):
"""A wrapper of an unbound `Controller` method to be bound into a `Command`.

This generic class stores an unbound `Controller` method - effectively a function
Expand All @@ -59,24 +169,33 @@ class UnboundCommand(Method[Controller_T]):
"""

def __init__(
self, fn: UnboundCommandCallback[Controller_T], *, group: str | None = None
self,
fn: UnboundCommandCallback[Controller_T, P, T],
*,
group: str | None = None,
) -> None:
super().__init__(fn, group=group)

def _validate(self, fn: UnboundCommandCallback[Controller_T]) -> None:
def _validate(self, fn: UnboundCommandCallback[Controller_T, P, T]) -> None:
super()._validate(fn)

if not len(self.parameters) == 1:
raise TypeError("Command method cannot have arguments")
if not self.parameters:
raise TypeError(f"Command {fn.__qualname__} must be a method, taking self")

# The leading parameter is the ``Controller`` this is bound to, not an
# argument of the command.
_validate_arguments(self.signature, fn, skip=1)
_validate_return(self.signature, fn)

def bind(self, controller: Controller_T) -> Command:
def bind(self, controller: Controller_T) -> Command[P, T]:
return Command(MethodType(self.fn, controller), group=self.group)


def command(
*, group: str | None = None
) -> Callable[
[UnboundCommandCallback[Controller_T]], UnboundCommandCallback[Controller_T]
[UnboundCommandCallback[Controller_T, P, T]],
UnboundCommandCallback[Controller_T, P, T],
]:
"""Decorator to register a `Controller` method as a `Command`

Expand All @@ -88,8 +207,8 @@ def command(
"""

def wrapper(
fn: UnboundCommandCallback[Controller_T],
) -> UnboundCommandCallback[Controller_T]:
fn: UnboundCommandCallback[Controller_T, P, T],
) -> UnboundCommandCallback[Controller_T, P, T]:
setattr(fn, "__unbound_command__", UnboundCommand(fn, group=group)) # noqa: B010

return fn
Expand Down
Loading
Loading