diff --git a/docs/user-guide/Configuration.md b/docs/user-guide/Configuration.md new file mode 100644 index 0000000..efebb22 --- /dev/null +++ b/docs/user-guide/Configuration.md @@ -0,0 +1,211 @@ + +# Configuring `pydase` + +## Do I Need to Configure My `pydase` Service? + +`pydase` services work out of the box without requiring any configuration. However, you +might want to change some options, such as the web server port or logging level. To +accommodate such customizations, `pydase` allows configuration through environment +variables - avoiding hard-coded settings in your service code. + +Why should you avoid hard-coding configurations? Here are two reasons: + +1. **Security**: + Protect sensitive information, such as usernames and passwords. By using environment + variables, your service code can remain public while keeping private information + secure. + +2. **Reusability**: + Services often need to be reused in different environments. For example, you might + deploy multiple instances of a service (e.g., for different sensors in a lab). By + separating configuration from code, you can adapt the service to new requirements + without modifying its codebase. + +Next, we’ll walk you through the environment variables `pydase` supports and provide an +example of how to separate service code from configuration. + +## Configuring `pydase` Using Environment Variables + +`pydase` provides the following environment variables for customization: + +- **`ENVIRONMENT`**: + Defines the operation mode (`"development"` or `"production"`), which influences + behaviour such as logging (see [Logging in pydase](https://github.com/tiqi-group/pydase?tab=readme-ov-file#logging-in-pydase)). + +- **`SERVICE_CONFIG_DIR`**: + Specifies the directory for configuration files (e.g., `web_settings.json`). Defaults + to the `config` folder in the service root. Access this programmatically using: + + ```python + import pydase.config + pydase.config.ServiceConfig().config_dir + ``` + +- **`SERVICE_WEB_PORT`**: + Defines the web server’s port. Ensure each service on the same host uses a unique + port. Default: `8001`. + +- **`GENERATE_WEB_SETTINGS`**: + When `true`, generates or updates the `web_settings.json` file. Existing entries are + preserved, and new entries are appended. + +### Configuring `pydase` via Keyword Arguments + +Some settings can also be overridden directly in your service code using keyword +arguments when initializing the server. This allows for flexibility in code-based +configuration: + +```python +import pathlib +from pydase import Server +from your_service_module import YourService + +server = Server( + YourService(), + web_port=8080, # Overrides SERVICE_WEB_PORT + config_dir=pathlib.Path("custom_config"), # Overrides SERVICE_CONFIG_DIR + generate_web_settings=True # Overrides GENERATE_WEB_SETTINGS +).run() +``` + +## Separating Service Code from Configuration + +To decouple configuration from code, `pydase` utilizes `confz` for configuration +management. Below is an example that demonstrates how to configure a `pydase` service +for a sensor readout application. + +### Scenario: Configuring a Sensor Service + +Imagine you have multiple sensors distributed across your lab. You need to configure +each service instance with: + +1. **Hostname**: The hostname or IP address of the sensor. +2. **Authentication Token**: A token or credentials to authenticate with the sensor. +3. **Readout Interval**: A periodic interval to read sensor data and log it to a + database. + +Given the repository structure: + +```bash title="Service Repository Structure" +my_sensor +├── pyproject.toml +├── README.md +└── src + └── my_sensor + ├── my_sensor.py + ├── config.py + ├── __init__.py + └── __main__.py +``` + +Your service might look like this: + +### Configuration + +Define the configuration using `confz`: + +```python title="src/my_sensor/config.py" +import confz +from pydase.config import ServiceConfig + +class MySensorConfig(confz.BaseConfig): + instance_name: str + hostname: str + auth_token: str + readout_interval_s: float + + CONFIG_SOURCES = confz.FileSource(file=ServiceConfig().config_dir / "config.yaml") +``` + +This class defines configurable parameters and loads values from a `config.yaml` file +located in the service’s configuration directory (which is configurable through an +environment variable, see [above](#configuring-pydase-using-environment-variables)). +A sample YAML file might look like this: + +```yaml title="config.yaml" +instance_name: my-sensor-service-01 +hostname: my-sensor-01.example.com +auth_token: my-secret-authentication-token +readout_interval_s: 5 +``` + +### Service Implementation + +Your service implementation might look like this: + +```python title="src/my_sensor/my_sensor.py" +import asyncio +import http.client +import json +import logging +from typing import Any + +import pydase.components +import pydase.units as u +from pydase.task.decorator import task + +from my_sensor.config import MySensorConfig + +logger = logging.getLogger(__name__) + + +class MySensor(pydase.DataService): + def __init__(self) -> None: + super().__init__() + self.readout_interval_s: u.Quantity = ( + MySensorConfig().readout_interval_s * u.units.s + ) + + @property + def hostname(self) -> str: + """Hostname of the sensor. Read-only.""" + return MySensorConfig().hostname + + def _get_data(self) -> dict[str, Any]: + """Fetches sensor data via an HTTP GET request. It passes the authentication + token as "Authorization" header.""" + + connection = http.client.HTTPConnection(self.hostname, timeout=10) + connection.request( + "GET", "/", headers={"Authorization": MySensorConfig().auth_token} + ) + response = connection.getresponse() + connection.close() + + return json.loads(response.read()) + + @task(autostart=True) + async def get_and_log_sensor_values(self) -> None: + """Periodically fetches and logs sensor data.""" + while True: + try: + data = self._get_data() + # Write data to database using MySensorConfig().instance_name ... + except Exception as e: + logger.error( + "Error occurred, retrying in %s seconds. Error: %s", + self.readout_interval_s.m, + e, + ) + await asyncio.sleep(self.readout_interval_s.m) +``` + +### Starting the Service + +The service is launched via the `__main__.py` entry point: + +```python title="src/my_sensor/__main__.py" +import pydase +from my_sensor.my_sensor import MySensor + +pydase.Server(MySensor()).run() +``` + +You can now start the service with: + +```bash +python -m my_sensor +``` + +This approach ensures the service is fully configured via the `config.yaml` file, +separating service logic from configuration. diff --git a/mkdocs.yml b/mkdocs.yml index b360f0b..40765de 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,6 +11,7 @@ nav: - Understanding Tasks: user-guide/Tasks.md - Understanding Units: user-guide/Understanding-Units.md - Validating Property Setters: user-guide/Validating-Property-Setters.md + - Configuring pydase: user-guide/Configuration.md - Developer Guide: - Developer Guide: dev-guide/README.md - API Reference: dev-guide/api.md