UniteLabs
Tutorial

SiLA Endpoints

UniteLabs CDK and the SiLA component library.

SiLA makes the distinction between Commands and Properties that commands may be parameterized, but properties may not. At UniteLabs we take a different philosophical approach to grouping functionality that distinguishes between Data Endpoints, which are used to return data to the user and Controls which have real-world physical device interactions associated with them. Data endpoints may be represented by observable or unobservable properties as well as unobservable commands, whereas Controls may only be represented by Commands.

Before we dive into the differences between these four groups, let's first learn about the shared attributes of SiLA Endpoints.

Shared Attributes

The UniteLabs CDK offers function decorators to create SiLA Commands and Properties which auto-generate SiLA identifiers, display names, and descriptions for methods, their parameters, and their returns based on a function's typing and its docstring representation, ensuring that our SiLA client is strongly typed with a human-readable interface.

from unitelabs.cdk import sila


class CustomException(Exception):
    """Custom error message."""

@sila.SiLAMethod()
async def method_name(self, arg: arg_type, ...) -> return_type:
    """
    Top-level description of the method.

    Args:
      ArgIdentifier: Description of `arg`,
        which can extend over multiple lines.
      ...

    Returns:
      ReturnIdentifier: Description of return value.

    Raises:
      CustomException: Description of the conditions under which the error is raised,
        and how to fix and/or avoid it.
    """

Listing 1: A general template for declaring Commands and Properties, where SiLAMethod represents one of ObservableCommand, ObservableProperty, UnobservableCommand, UnobservableProperty.

Identifiers and Display Names

Identifiers are auto-generated based on the method name. The method name get_server_name is turned into the property identifier "ServerName". Several prefixes are auto-detected and infer certain aspects of the method:

  • "get"-prefix is subtracted for unobservable properties
  • "subscribe"-prefix is subtracted for observable properties

When allowing the CDK to derive its own identifiers and display names, acronyms and abbreviations that are part of the identifier and display name will be turned into lowercase words. A method named "get_server_uuid" is transformed into "ServerUuid" (identifier) and "Server Uuid" (display name) respectively. However, in some cases, like the SiLA Service feature, identifier and display name are defined as "ServerUUID" and "Server UUID". If an identifier is specified explicitly in the SiLA decorator, the display name is derived from it, and vice-versa.

@sila.UnobservableProperty(name="Server UUID")
async def get_server_uuid(self) -> str:
    """Get the UUID for the server."""

Listing 2: Explicitly specifying the display name of a sila.UnobservableProperty.

All UniteLabs CDK SiLA decorators, i.e. sila.ObservableCommand, sila.ObservableProperty, sila.UnobservableCommand, sila.UnobservableProperty, accept identifier, name, and errors as parameters for manually setting these values, rather than having them be derived. These decorator arguments serve as an alternative to inference based on the function signature and docstrings, which follow a slightly modified google-doc style:

from unitelabs.cdk import sila

class ConversionError(Exception):
    """Unable to convert value."""

@sila.ObservableCommand(name="Display Name Override")
async def get_complex_value(
    self,
    param_a: str,
    param_b: str,
    *,
    status: sila.Status,
    intermediate: sila.Intermediate[int],
) -> tuple[str, int]:
    """
    Create a complex value from two input values.

    Examples:
      Example for how to use this function:
      >>> complex = await self.get_complex_value("a", "2")
      >>> print(complex)
      ("a", 2)

    Args:
      ParamA: Description of `param_a`,
        where the identifier matches what would be inferred.
      SecondParam: Description of `param_b`,
        with an overridden display name of 'Second Param'.

    Yields:
      NamedIntermediateValue: Description of intermediate integer value.

    Returns:
      ReturnValueA: Description of the returned string value from `param_a`,
        with the display name 'Return Value A'.
      IntValue: Description of the returned integer value from `param_b`,
        with the display name 'Int Value'.

    Raises:
      ConversionError: If `param_b` is not convertible into an integer.
    """
    ...

Listing 3: Maximal example of docstring functionality, with parameters, responses, and intermediate responses, as well as examples and error descriptions.

Parameters

Parameters can be inferred based on the type-hints provided in the function declaration. These type-hints must be combined with annotation of the parameters' human-readable name and description, which can be set using the Args docstring section. Parameters must be declared in the order that they are declared on the function.

src/unitelabs/tutorial/features/sila_endpoints.py
@sila.UnobservableCommand()
async def parameter_example(self, seed: int) -> float:
    """
    Stably generate a random number.

    Args:
      Seed: Number used to initialize the pseudorandom number generator.

    Returns:
      RandomNumber: A randomly generated number.
    """

    import random

    random.seed(seed)

    return random.random()

Listing 4: Declaring user input parameters for a SiLA Command.

Parameter types must be valid SiLA Data Types.

Responses

Responses are inferred by the specified return type. Responses can be annotated within the Returns section of a docstring.

While properties must only have one response, commands may have multiple responses. Multiple responses can be described simply by explicitly declaring the identifiers and descriptions in the Returns section. In this case, the method should return a tuple of all responses and the return statement looks like this:

src/unitelabs/tutorial/features/sila_endpoints.py
@sila.UnobservableCommand()
async def responses_example(self) -> tuple[int, str]:
    """
    Create random stuff.

    Returns:
      Answer: The answer to the Ultimate Question of Life,
        the Universe, and Everything.
      RandomFact: Some random fact to always keep in mind.
    """

    return 42, "Don't Panic!"

Listing 5: Declaring response values for a SiLA method.

Return types must be valid SiLA Data Types.

Technical Hint: While SiLA Properties may only return a single response, a dataclass allows you to return a single response with multiple named values. In comparison, SiLA Commands may use the tuple type annotation in combination with display names declared in the docstring to return multiple responses without a dataclass.

Observable Properties

An observable property is a property that can be read at any time and that offers a subscription mechanism to observe any change of its value.

For more information about how to create and manage observable data sources, check out our Subscriptions Guideline.

Add the ObservableProperty decorator to a method to turn it into an observable property.

src/unitelabs/tutorial/features/sila_endpoints.py
@sila.ObservableProperty()
async def subscribe_random_numbers(self) -> sila.Stream[int]:
    """Stream of random numbers."""

    import asyncio
    import random

    while True:
        yield random.randint(0, 42)
        await asyncio.sleep(1)

Listing 6: A basic SiLA ObservableProperty, where display names and identifiers are inferred.

Technical Hint: The sila.Stream annotation is an alias for collections.abc.AsyncGenerator. Python does not allow return statements in async generators, therefore only yield is allowed.

Unobservable Properties

An unobservable property is a property that can be read at any time, but no subscription mechanism is provided to observe its changes.

Add the UnobservableProperty decorator to a method to turn it into an unobservable property.

src/unitelabs/tutorial/features/sila_endpoints.py
@sila.UnobservableProperty()
async def get_random_number(self) -> int:
    """A single random number."""

    import random

    return random.randint(0, 42)

Listing 7: A basic SiLA UnobservableProperty, where display names and identifiers are inferred.

Unlike the observable property from the previous section, this unobservable property does not provide a stream of constantly updating values but only returns get_random_number once.

Refinements to the identifier for SiLA Properties requires setting the name or identifier in the sila decorator:

src/unitelabs/tutorial/features/sila_endpoints.py
@sila.UnobservableProperty(name="Number")
async def get_random_number(self) -> int:
    """Get a single random number."""

Listing 8: Declaring a SiLA Property, with an explicit named response which differs from the method name, i.e. without inference.

Unobservable Commands

An unobservable command is a command that triggers an action on the device and returns at most a single response. UnobservableCommands may act as both Data Endpoints and Controls, depending on their functionality.

Unobservable commands are best used for parameterized data accession and changing settings on the server, i.e. passing data from Client to Server or vis-versa, as these actions are short-lived and respond immediately.

Add the UnobservableCommand decorator to a method to turn it into an unobservable command.

src/unitelabs/tutorial/features/sila_endpoints.py
@sila.UnobservableCommand()
async def perform_action(self, name: str) -> tuple[bool, datetime.datetime]:
    """
    Perform the action on the device identified by `name`.

    Args:
      ActionName: The name of the action to be performed.

    Returns:
      Success: Whether or not the action was successful.
      SucceedTime: The timestamp for when the action succeeded.
    """
    ...

Listing 9: Declaring a SiLA UnobservableCommand.

Technical Hint: Remember that here a single response means only one time. The type of the response may be a complex, inferred data structure (with tuple) or a dataclass, thus allowing multiple values to be sent at once.

Here we attempt to perform a named action and report whether or not that named action was successfully performed. The action does not report back any intermediary values and thus is unobservable.

As noted, unobservable commands must not necessarily act as controls, but may also be functions which have input parameters (which are not allowed for SiLA properties) and use those parameters to selectively get information from the device, i.e. extracting a single value from a dictionary based on its key.

src/unitelabs/tutorial/features/sila_endpoints.py
@sila.UnobservableCommand()
async def get_variable_value(self, variable: str) -> str:
    """
    Read out the variable value for the currently active configuration.

    Args:
      VariableName: The name of the variable in the active configuration
        to get the value for.

    Returns:
      Value: The variable value.
    """

    return self.active_config.get(variable, "")

Listing 10: Declaring a SiLA UnobservableCommand which acts as a Data Endpoint.

Observable Commands

An observable command is a command that triggers and action on the device and can be subscribed. During the subscription, intermediate results can be returned that contain information on the command execution status, such as remaining execution time, progress and any other arbitrary data useful for consumers of the method.

Any action which does not return an immediate result is best made observable to ensure completion of the command execution and prevent loss of data that could result from a SiLA Client timing out or disconnecting.

Add the ObservableCommand decorator to a method to turn it into an observable command.

src/unitelabs/tutorial/features/sila_endpoints.py
@sila.ObservableCommand()
async def iterative_method(
    self,
    seed: int,
    iterations: int,
    *,
    status: sila.Status,
    intermediate: sila.Intermediate[int],
) -> float:
    """
    Creates a random number based on a `seed` after `iterations`.

    Args:
      Seed: Number used to initialize the pseudorandom number generator.
      NumberOfIterations: How many times to iterate before number generation.

    Yields:
      CurrentIteration: The current iteration.

    Returns:
      RandomNumber: A random number.
    """

    import asyncio
    import random

    random.seed(seed)

    total = iterations + 1
    for x in range(1, total):
        intermediate.send(x)
        status.update(progress=(x / total))
        await asyncio.sleep(1)

    return random.random()

Listing 11: Declaring a SiLA ObservableCommand.

Observable command functions may take the following keyword arguments:

  • status: sila.Status
  • intermediate: sila.Intermediate[T] where T is a valid SiLA data type

If the function signature contains these two arguments, one may then use the objects passed in to send status updates with status.update() or to send IntermediateResponse values via intermediate.send().

For more information about how to create and manage observable data sources, check out our Subscriptions Guideline.

Transitioning to V0.5.0

Prior to V0.5.0, the CDK support a directive-style docstring declaration, which has been deprecated and will raise a DeprecationWarning for all usages.

Pre-V0.5.0 also supported a decorator-based declaration for parameters, responses, and intermediate responses, which have been completely removed, and will cause an ImportError.

All directives and decorators have been replaced with our googleDoc-style docstring inference system.

For example instead of sila.Parameter or the Parameter parameter directive

@sila.Parameter(identifier="Human-ReadableName", name="Human-Readable Name", description="Description of the parameter.")
@sila.UnobservableProperty(description="Description of the method.")
async def get_value(self, arg: arg_type) -> return_type:
    ...

Similarly for sila.Response and the return directive:

@sila.Response(name="Return Value Display Name", description="Description of the response.")
@sila.UnobservableProperty(description="Description of the method.")
async def get_value(self) -> return_type:
    ...

And sila.IntermediateResponse and the yields directive:

@sila.IntermediateResponse(name="Intermediate Value Display Name", description="Description of yielded intermediate response.")
@sila.ObservableCommand(description="Description of the method.")
async def get_value(self, intermediate: sila.Intermediate[intermediate_type]) -> return_type:
    ...
Types should never be included in docstrings, as these are already documented in the function signature. The UniteLabs docstring format follows the Google StyleGuide. Our system relies on the presence of types in the function signature to determine the SiLA data types for parameters, intermediate responses, and response values.

::