Configuration
Connectors commonly require some amount of user-facing configuration to get started. Whether this is something as simple as needing to know to which serial port a device is connected, or something as complex as allowing a single Connector to communicate with multiple related devices with different startup conditions, UniteLab's new configuration system, built on top of the popular pydantic library, is here to help.
Prerequisites
- CDK v0.5.0
- A Connector, check out our Installation Guide to create a new Connector project with our
connector-factorycookiecutter and the Connector Walkthrough to get started writing your own Connector.
The Base Config
The easiest way to learn more about the configuration of a connector is with the config show command. After creating a new connector called connector-starter with the connector-factory, you would call config show as follows:
uv run config show --app unitelabs.connector_starter:create_app
This command will display a table of the configurable values and their associated type hints, descriptions, and default values.
--app option is required for Windows. On MacOS and Linux, commands like connector start and config will generally work without providing this option. On these operating systems, needing to provide --app often points to an error in the connector package.When creating a connector there are some subset of configurable values that are shared by all connectors. All of the requried and optional configuration values for a SiLA server are embedded in the ConnectorBaseConfig.
ConnectorBaseConfig contains four main sections:
- sila_server - All of the configuration values for the SiLA server, documented in the SILA Server Config Reference.
- cloud_server_endpoint - All of the configuration values for the cloud server, which allows one to expose and interact with the Connector on the UniteLabs platform, documented in the Cloud Server Config Reference.
- discovery - All of the configuration values for multicast DNS (mDNS) and DNS-based Service Discovery(DNS-SD).
- logging - Entrypoint for setting python logging configuration, accepts python logging dict as defined in the python logging documentation.
All the specific parameters contained in these base config groups can be viewed with the config show command using the --output argument, e.g. config show --output sila_server or config show --output cloud_server_endpoint.
User's Guide
Connector configuration comes with its own CLI to create configuration files. To create a new configuration file simply call:
uv run config create --app unitelabs.connector_starter:create_app
The create command by default will create a file called config.json in the current working directory. We support yaml and json file-types and allow users to specify their own path and file type by adding the --path argument:
uv run config create --app unitelabs.connector_starter:create_app --path /path/to/config.prod.yaml
Starting the connector named connector-starter with a configuration file at the default path is as simple as:
uv run connector start --app unitelabs.connector_starter:create_app -vvv
Both connector start and certificate generate CLIs have a --config-path or -cfg argument that allows one to pass in a path to their configuration file, enabling users to create and apply multiple configurations for their connectors.
uv run connector start --app unitelabs.connector_starter:create_app -cfg /path/to/config.prod.yaml -vvv
For more information about the config CLI, check out the help documentation:
uv run config --help
or for a sub-command, e.g. create:
uv run config create --help
Transitioning from .env
Transitioning .env files to the new system requires moving values from the .env file to the newly create config.json or config.yaml file.
Values prefixed with SILA_SERVER__ are moved into the "sila_server" section of the new config file, while values prefixed with CLOUD_SERVER_ENDPOINT__ are moved into the "cloud_server_endpoint" section.
SILA_SERVER__UUID=00000000-0000-0000-0000-000000000000
SILA_SERVER__HOSTNAME=0.0.0.0
SILA_SERVER__PORT=50001
CLOUD_SERVER_ENDPOINT__HOSTNAME=ffffffff-ffff-ffff-ffff-ffffffffffff.unitelabs.io
CLOUD_SERVER_ENDPOINT__PORT=443
CLOUD_SERVER_ENDPOINT__TLS=True
SERIAL_PORT=/dev/ttyUSB0
The equivalent config.json file would include the following values:
{
"sila_server": {
"uuid": "00000000-0000-0000-0000-000000000000",
"hostname": "0.0.0.0",
"port": 50001
},
"cloud_server_endpoint": {
"hostname": "ffffffff-ffff-ffff-ffff-ffffffffffff.unitelabs.io",
"port": 443,
"tls": true
},
"serial_port": "/dev/ttyUSB0"
}
Developer's Guide
To extend the config with your device specific variables the following points need to be considered.
Requirements:
- The derived config class must be a dataclass.
- All configurable values must have a default; this allows the creation of a "default" config, which users can then fill in with their own configuration values.
Limitations:
- Does not allow for asynchronous default factories, e.g. if you need to fetch some data from a remote source to populate a default value.
- Our
pydanticintegration is compatible with pydantic concepts which can be used withpydantic.dataclasses.dataclassorpydantic.TypeAdapter, but not those which are restricted to use with thepydantic.BaseModel. - We re-export a subset of
pydanticmethods as a utility, you must addpydanticas a dependency and import it directly to access additionalpydanticmethods that are not explicitly exported by the CDK.
import dataclasses
from unitelabs.cdk.config import ConnectorBaseConfig
@dataclasses.dataclass
class ConnectorConfig(ConnectorBaseConfig):
number_of_pipettes: int = 8
"""The number of pipettes connected to the device."""
Listing 1: Basic ConnectorBaseConfig derivation with an additional parameter.
In the Connector's __init__.py:
from .config import ConnectorConfig
from unitelabs.cdk import Connector
async def create_app(config: ConnectorConfig):
app = Connector(config)
# use config values to adapt the connector
yield app
Listing 2: Incorporation of a derived ConnectorBaseConfig into a Connector.
__init__.py file or in a separate file, as shown here. However, if your config is in a separate file, you must import it into your __init__.py. Our configuration system relies on the config class being present when the connector is loaded during connector start to validate the configuration values.If we were to now run config create, it would create the following default config file:
{
"sila_server": {
"hostname": "0.0.0.0",
"port": 0,
"tls": false,
"require_client_auth": false,
"root_certificates": null,
"certificate_chain": null,
"private_key": null,
"options": {},
"uuid": "adf054d6-4503-486a-82cc-7b3f4c159b46",
"name": "SiLA Server",
"type": "ExampleServer",
"description": "",
"version": "0.1",
"vendor_url": "https://sila-standard.com"
},
"cloud_server_endpoint": {
"hostname": "localhost",
"port": 50001,
"tls": false,
"root_certificates": null,
"certificate_chain": null,
"private_key": null,
"reconnect_delay": 10000.0,
"options": {}
},
"discovery": {
"network_interfaces": [],
"ip_version": "ipv4",
},
"logging": null,
"number_of_pipettes": 8
}
Listing 3: Basic derived ConnectorBaseConfig's config.json output.
Running connector start would start our connector which would have the default name of "SiLA Server" as we have not yet overridden the default Connector configuration values.
Let's modify our config to add override the default values for our SiLA server:
import dataclasses
from importlib.metadata import version
from unitelabs.cdk.config import ConnectorBaseConfig, SiLAServerConfig
@dataclasses.dataclass
class MyConfig(ConnectorBaseConfig):
number_of_pipettes: int = 8
"""The number of pipettes connected to the device."""
sila_server: SiLAServerConfig = dataclasses.field(default_factory=lambda: SiLAServerConfig(
name="My Server",
type="Test",
description="My Test Server written with the UniteLabs CDK.",
version=str(version("unitelabs-my-connector")),
vendor_url= "https://unitelabs.io/",
))
Listing 4: A derived ConnnectorBaseConfig with SiLAServerConfig overrides.
Now running config create we see the default value for sila_server.name is set to "My Server".
{
"sila_server": {
"hostname": "0.0.0.0",
"port": 0,
"tls": false,
"require_client_auth": false,
"root_certificates": null,
"certificate_chain": null,
"private_key": null,
"options": {},
"uuid": "adf054d6-4503-486a-82cc-7b3f4c159b46",
"name": "My Server",
"type": "Test",
"description": "My Test Server written with the UniteLabs CDK.",
"version": "0.1.0",
"vendor_url": "https://unitelabs.io/"
},
"cloud_server_endpoint": {
"hostname": "localhost",
"port": 50001,
"tls": false,
"root_certificates": null,
"certificate_chain": null,
"private_key": null,
"reconnect_delay": 10000.0,
"options": {}
},
"discovery": {
"network_interfaces": [],
"ip_version": "ipv4",
},
"logging": null,
"number_of_pipettes": 8
}
Delayed Defaults
We may want to have some dynamically generated default values for our connector. We can use the delayed_default method to allow values to have a delayed evaluation.
import dataclasses
import typing
from unitelabs.cdk.config import ConnectorBaseConfig, delayed_default
@dataclasses.dataclass
class MyConfig(ConnectorBaseConfig):
type: typing.Literal["A", "B"] = "A"
"""The type of connector to run."""
name: str = dataclasses.field(
default_factory=delayed_default(lambda self: f"{self.type} Connector")
)
"""The human readable name of the connector type."""
Listing 5: Applying delayed default values to a derived ConnnectorBaseConfig.
Delayed defaults are evaluated on accession if no explicit override value has been provided so:
>>> config = MyConfig()
>>> name = config.name # config.name is resolved now
>>> print(name)
"A Connector"
>>> config = MyConfig(type="B", name="overridden")
>>> name = config.name # no evaluation here as `name` is already set
>>> print(name)
"overridden"
Listing 6: Attribute setters with delayed_default are evaluated on accession.
Validations
Standard typing validations come baked in with no special declaration. All built-in validations come with informative error messages that point directly to the misconfigured field.
Validations fall into two categories: field validations, which apply to one or more fields of the configuration, and config validations, which apply to the entire configuration, e.g. for cases of inter-dependent fields.
These validations are applied when the configuration is loaded through the ConnnectorBaseConfig.validate method, which is called automatically when the connector is started. The validate method returns a validated Configuration instance as a pydantic dataclass, which also applies field validations on assignment.
It is important to note that pydantic relies on a ValueError being raised as a result of invalid configuration, we therefore suggest always using the CDK's ConfigurationError (our custom ValueError) as shown in the upcoming sections. All validation methods MUST throw some ValueError for failure cases in order to be caught by pydantic.
Field Validation
Field validations apply to single fields in the configuration, and can be used to further constrain allowed user-supplied values. Field-level validations are applied as property descriptors, meaning that they are applied both when the configuration is loaded via the validate method and when a field is set post-validation.
unitelabs.cdk.config.ConfigurationError: Invalid configuration for <ConfigClassName>: 1 validation error for <ConfigClassName>"
<Field>
Value error: <ErrorMessage>
Listing 7: How field-level ConfigurationErrors are displayed.
import dataclasses
import typing
from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig, Field
LargeInt: typing.TypeAlias = typing.Annotated[int, Field(gt=1_000_000)]
@dataclasses.dataclass
class ConnectorConfig(ConnectorBaseConfig):
large_int: LargeInt = 1_000_001
"""A large integer."""
another_large_int: LargeInt = 1_000_001
"""Another large integer."""
Listing 8: Using typing.Annotated field annotation for validating that numbers are greater than (gt) 1,000,000.
Calling connector start with a configuration where large_int=1 will result in the following error message:
unitelabs.cdk.config.ConfigurationError: Invalid configuration for MyConfig: 1 validation error for MyConfig
large_int
Input should be greater than 1000000 [type=greater_than, input_value=1, input_type=int]
For further information visit https://errors.pydantic.dev/2.11/v/greater_than
Additionally, Field annotations allow one to add metadata to fields, such as descriptions and examples, which are included in the config's JSONschema representation, accessible with the config schema CLI command.
import uuid
import pydantic
from unitelabs.cdk.config import Field
@dataclasses.dataclass
class ConnectorConfig(ConnectorBaseConfig):
large_int: typing.Annotated[int, Field(gt=1_000_000, description="A large integer.", example=5_000_000)] = 1_000_001
uuid: typing.Annotated[
str,
pydantic.WithJsonSchema({"type": "string", "format": "uuid"})
] = dataclasses.field(default_factory=lambda: str(uuid.uuid4()))
"""A unique identifier for the connector instance."""
Listing 9: Using typing.Annotated field annotation for JSONschema specification.
Pydantic uses Field annotations and the validate_field decorator to create property descriptors for the dataclass field, thus ensuring that validations are always applied.
When using the either of the above MyConfig classes:
config = MyConfig.validate({"large_int":5_000_000})
print(config.large_int) # 5000000
config.large_int = 500 # Raises pydantic.ValidationError
Listing 11: Field-level validations are also evaluated on assignment when using a validated config, i.e. one that has been created through the validate entrypoint.
Field annotation arguments
Using the Annotated pattern with the Field annotation additionally ensures that all validators are included in the Config's JSONschema representation, i.e. output of config schema will include Structural Validations.
Number Validations:
| Argument | Description |
|---|---|
gt | input must be greater than value |
ge | input must be greater than or equal to value |
lt | input must be less than value |
le | input must be less than or equal value |
multiple_of | input must be a multiple of this value, e.g. for multple_of=2 valid values would include 2 and 4 but not 3 or 1.5 |
decimal_places | input's decimal precision must not exceed this value |
String Validations:
| Argument | Description |
|---|---|
pattern | input must match the pattern of this regex value |
max_digits | input must contain a maximum of this number of allowed digits |
min_length | input must be at least of the given length |
max_length | input must not exceed the given length |
List Validations:
| Argument | Description |
|---|---|
min_length | input list must contain a minimum of this number of values |
max_length | input list must contain a maximum of this number of values |
Config Validation
Config-level validations do not automatically offer feedback pointing to a specific field and are not re-evaluated when fields are set post-validation.
unitelabs.cdk.config.ConfigurationError: Invalid configuration for <ConfigClassName>: 1 validation error for <ConfigClassName>"
Value error, <ErrorMessage>
Listing 12: How config-level ConfigurationErrors are displayed.
This is because config-level configurations look to validate multiple inter-dependent fields and the validity of specific fields is determined in the context of the entire config.
Let's take for example a setup where we have two modes for running something, one of which requires an additional configuration value:
Validations can be applied in the __post_init__ of the configuration dataclass.
import dataclasses
import typing_extensions as typing
from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig
@dataclasses.dataclass
class DependentFieldsConfig(ConnectorBaseConfig):
mode: typing.Literal["A", "B"] = "A"
b_number: int = -1
def __post_init__(self) -> None:
if self.mode == "B" and self.b_number == -1:
msg = "When mode=B, b_number must also be supplied."
raise ConfigurationError(msg)
Listing 13: Using dataclasses __post_init__ for validations.
This setup will expose to the user a default configuration for mode "A". When the user adjusts the values in their config file and tries to run the connector they will receive clear feedback from the ConfigurationError about the changes that are necessary to make their configuration valid.
Best Practices
Do not change configuration values in the Connector create_app function; this is important in order to best respect user settings.
Values which can be derived from the device itself should rather be retrieved from the device in the create_app function after establishing communication, especially in instances when the user would be penalized for incorrectly setting the configuration value.
When creating a configuration value of this sort, design the create_app function to ensure the value is not required for starting the connector and clearly document the endpoint that users should call to retrieve the correct configuration values in the docstring for that value.
Connector configuration should best be focused on:
- establishing connection to the device
- adjustment to communication behaviors, e.g. reconnection delays or timeouts
- adjustment to connector reactivity behaviors, e.g. tolerance settings for sensors
- establishing default start-up conditions
Transitioning to V0.5.0
import collections.abc
from importlib.metadata import version
from unitelabs.cdk import Config, Connector
__version__ = version("unitelabs-connector")
class ConnectorConfig(Config):
serial_port: str
device: typing.Literal["DeviceTypeA", "DeviceTypeB"]
async def create_app() -> collections.abc.AsyncGenerator[Connector]:
config = CustomConfig() # here env was already modified by the start command and is now loaded in
app = Connector(
{
"sila_server": {
"name": f"{config.device}",
"type": "Device Type",
"description": f"{config.device} is a Device Type instrument.",
"version": str(__version__),
"vendor_url": "https://unitelabs.io/",
}
}
)
# app.register() # register Features on the Connector
yield app
# cleanup
Listing 15: Usage of old Config for versions of the CDK < v0.5.0.
All fields should have a default value; these default values should, when possible, allow for users to start the connector without input.
import collections.abc
import dataclasses
import typing
from importlib.metadata import version
from unitelabs.cdk import Connector
from unitelabs.cdk.config import ConnectorBaseConfig, SiLAServerConfig, delayed_default
from unitelabs.bus.utils.device_manager import SerialDeviceManager
__version__ = version("unitelabs-connector")
@dataclasses.dataclass
class ConnectorConfig(ConnectorBaseConfig):
serial_port: str = "/dev/ttyUSB0"
"""The serial port to which the device is connected."""
device: typing.Literal["DeviceTypeA", "DeviceTypeB"] = "DeviceTypeA"
"""The type of device to which the connector should connect."""
sila_server: SiLAServerConfig = dataclasses.field(
default_factory=delayed_default(lambda self: SiLAServerConfig(
name=f"{self.device}",
type="Device Type",
description=f"Connector for the {self.device} instrument.",
version=str(__version__),
vendor_url= "https://unitelabs.io/",
)))
async def create_app(config: CustomConfig) -> collections.abc.AsyncGenerator[Connector]:
app = Connector(config)
# app.register() # register Features on the Connector
yield app
# cleanup
Listing 16: Upgraded v0.5.0 configuration using ConnectorBaseConfig.
The SILA Server Config
Required fields
- hostname - The name of the host which the server should bind to.
- port - The port which the server should bind to.
- name - The human readable name of the server.
- type - A human readable identifier for the grouping or type of instrument the server connects to.
- description - The use and purpose of the server.
- version - The version for the server, following the Semantic Version Specification.
- vendor_url - The URL to the website of the vendor or product which the server connects to.
Optional fields
For TLS encryption configuration (Further information available in our Security Guide.)
- tls - Whether or not TLS encryption should be applied to the server.
- certificate_chain - A path to, or the contents of, the PEM-encoded certificate chain, or
Noneif no certificate should be used. - private_key - A path to, or the contents of, the PEM-encoded private key, or
Noneif no private key should be used.
For gRPC connection configuration
- require_client_auth - Whether or not to require clients to be authenticated, used in combination with
root_certificates. - root_certificates - The PEM-encoded root certificates, or
Nonewhich uses the gRPC default location. - options - A dictionary of values for configuring the underlying gRPC connection, advanced configuration.
The Cloud Server Config
Connecting to a cloud server is optional for connectors. In order to configure a Connector to interact with the UniteLabs platform, we must set the required values of the CloudServerConfig.
Required fields
- hostname - The target hostname to connect to.
- port - The target port to connect to.
Optional fields
- tls - Whether or not TLS encryption should be applied to the server.
- root_certificates - The PEM-encoded root certificates, or
Nonewhich uses the gRPC default location. - certificate_chain - A path to, or the contents of, the PEM-encoded certificate chain, or
Noneif no certificate should be used. - private_key - A path to, or the contents of, the PEM-encoded private key, or
Noneif no private key should be used. - reconnect_delay - The time in ms to wait before attempting to reconnect the channel after an error occurs.
- options - A dictionary of values for configuring the underlying gRPC connection, advanced configuration.