Skip to content

Configure Your Application

cosalette uses pydantic-settings for configuration — environment variables, .env files, and CLI flag overrides work out of the box. This guide shows you how to extend the base Settings class for your app-specific needs.

Prerequisites

This guide assumes you've completed the Quickstart.

The Base Settings Class

The framework provides a Settings class with two built-in sub-models:

cosalette framework (built-in)
class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_nested_delimiter="__",
        env_file=".env",
        env_file_encoding="utf-8",
    )

    mqtt: MqttSettings = Field(default_factory=MqttSettings)
    logging: LoggingSettings = Field(default_factory=LoggingSettings)

These cover MQTT broker connection and logging configuration. Every cosalette app gets these for free — you only need to subclass Settings when you have app-specific fields.

Subclassing Settings

Create your own settings class with an env_prefix to namespace your environment variables:

settings.py
from pydantic import Field
from pydantic_settings import SettingsConfigDict

import cosalette


class Gas2MqttSettings(cosalette.Settings):  # (1)!
    model_config = SettingsConfigDict(
        env_prefix="GAS2MQTT_",  # (2)!
        env_nested_delimiter="__",
        env_file=".env",
        env_file_encoding="utf-8",
    )

    serial_port: str = Field(
        default="/dev/ttyUSB0",
        description="Serial port for the gas meter sensor.",
    )
    poll_interval: int = Field(
        default=60,
        ge=1,
        description="Polling interval in seconds.",
    )
  1. Inherit from cosalette.Settings to get mqtt and logging sub-models.
  2. env_prefix="GAS2MQTT_" means all environment variables start with GAS2MQTT_. For example: GAS2MQTT_SERIAL_PORT=/dev/ttyACM0.

Then pass the class to App:

app.py
app = cosalette.App(
    name="gas2mqtt",
    version="1.0.0",
    settings_class=Gas2MqttSettings,
)

Environment Variables and Nesting

The env_nested_delimiter="__" setting controls how nested models map to environment variables. With env_prefix="GAS2MQTT_":

Environment Variable Settings Field Default
GAS2MQTT_SERIAL_PORT serial_port /dev/ttyUSB0
GAS2MQTT_POLL_INTERVAL poll_interval 60
GAS2MQTT_MQTT__HOST mqtt.host localhost
GAS2MQTT_MQTT__PORT mqtt.port 1883
GAS2MQTT_MQTT__USERNAME mqtt.username None
GAS2MQTT_MQTT__PASSWORD mqtt.password None
GAS2MQTT_LOGGING__LEVEL logging.level INFO
GAS2MQTT_LOGGING__FORMAT logging.format json

Double underscore for nesting

The __ delimiter separates sub-model names from field names. GAS2MQTT_MQTT__HOSTsettings.mqtt.host. This is a pydantic-settings convention — see their nested models docs.

Using .env Files

Create a .env file in your project root:

.env
# MQTT broker
GAS2MQTT_MQTT__HOST=broker.local
GAS2MQTT_MQTT__PORT=1883
GAS2MQTT_MQTT__USERNAME=gas2mqtt
GAS2MQTT_MQTT__PASSWORD=supersecret

# Logging
GAS2MQTT_LOGGING__LEVEL=DEBUG
GAS2MQTT_LOGGING__FORMAT=text

# App-specific
GAS2MQTT_SERIAL_PORT=/dev/ttyACM0
GAS2MQTT_POLL_INTERVAL=30

The env_file=".env" in model_config tells pydantic-settings to load this file automatically. Environment variables set in the shell take precedence over .env values.

Don't commit .env to Git

Add .env to your .gitignore. Commit a .env.example with placeholder values instead, so new developers know which variables to set.

CLI Flag Overrides

cosalette's built-in CLI (powered by Typer) provides command-line flags that override settings:

# Override log level and format
gas2mqtt --log-level DEBUG --log-format text

# Use a different .env file
gas2mqtt --env-file /etc/gas2mqtt/.env

# Enable dry-run mode (uses mock adapters)
gas2mqtt --dry-run

Available CLI flags:

Flag Settings Path Description
--log-level logging.level Root log level
--log-format logging.format json or text
--dry-run Use dry-run adapter variants
--env-file Path to .env file
--version Print version and exit

Priority order (highest to lowest):

  1. CLI flags
  2. Environment variables
  3. .env file values
  4. Field defaults

Secrets with SecretStr

For sensitive values like passwords, use pydantic's SecretStr:

settings.py
from pydantic import Field, SecretStr
from pydantic_settings import SettingsConfigDict

import cosalette


class Gas2MqttSettings(cosalette.Settings):
    model_config = SettingsConfigDict(
        env_prefix="GAS2MQTT_",
        env_nested_delimiter="__",
        env_file=".env",
        env_file_encoding="utf-8",
    )

    serial_port: str = Field(default="/dev/ttyUSB0")
    api_key: SecretStr = Field(  # (1)!
        default=SecretStr(""),
        description="API key for cloud reporting.",
    )
  1. SecretStr masks the value in logs and repr() output. Access the actual value with settings.api_key.get_secret_value().

The built-in MqttSettings.password field is already a SecretStr — MQTT credentials are masked by default.

Validators

Use pydantic's field_validator or model_validator for custom validation:

settings.py
from pydantic import Field, field_validator
from pydantic_settings import SettingsConfigDict

import cosalette


class Gas2MqttSettings(cosalette.Settings):
    model_config = SettingsConfigDict(
        env_prefix="GAS2MQTT_",
        env_nested_delimiter="__",
        env_file=".env",
        env_file_encoding="utf-8",
    )

    serial_port: str = Field(default="/dev/ttyUSB0")
    poll_interval: int = Field(default=60, ge=1)

    @field_validator("serial_port")
    @classmethod
    def serial_port_must_be_device(cls, v: str) -> str:
        """Validate that serial_port looks like a device path."""
        if not v.startswith("/dev/"):
            msg = f"serial_port must be a /dev/ path, got: {v!r}"
            raise ValueError(msg)
        return v

Pydantic validation

Field constraints like ge=1 (greater than or equal to 1) are checked at instantiation time. If GAS2MQTT_POLL_INTERVAL=0 is set, pydantic raises a ValidationError before the app starts — failing fast is better than a runtime surprise.

Using Settings in Decorator Arguments

App.__init__ eagerly instantiates the settings_class, making app.settings available at decoration time — before the app is started. This lets you use configuration values directly in decorator arguments like interval=:

app.py
import cosalette
from pydantic import Field
from pydantic_settings import SettingsConfigDict


class Gas2MqttSettings(cosalette.Settings):
    model_config = SettingsConfigDict(
        env_prefix="GAS2MQTT_",
        env_nested_delimiter="__",
        env_file=".env",
        env_file_encoding="utf-8",
    )
    poll_interval: int = Field(default=60, ge=1)


app = cosalette.App(
    name="gas2mqtt",
    version="1.0.0",
    settings_class=Gas2MqttSettings,
)


@app.telemetry("counter", interval=app.settings.poll_interval)  # (1)!
async def counter() -> dict[str, object]:
    return {"impulses": 42}
  1. app.settings.poll_interval is evaluated when the module loads. Environment variables and .env files have already been read by this point. Set GAS2MQTT_POLL_INTERVAL=30 to override the default of 60.

How it works

When App(settings_class=Gas2MqttSettings) is called, the constructor runs Gas2MqttSettings() immediately. Since pydantic-settings reads environment variables and .env files at instantiation time, app.settings already reflects the runtime configuration when Python evaluates the decorator.

The CLI entrypoint (app.run()) may re-instantiate settings with --env-file support, but the decorator arguments are fixed at import time.

--help safety

Because app.settings is evaluated eagerly at import time, running myapp --help will crash if required environment variables are missing. For dynamic registration driven by settings (e.g. registering devices from a config file), use @app.on_configure instead — it runs after CLI parsing and is safe even without environment variables set.

Conditional device registration

The simplest approach is the enabled= parameter, available on all device decorators:

# Modern approach — enabled= parameter
@app.telemetry("debug", interval=10, enabled=app.settings.enable_debug_device)
async def debug_sensor() -> dict[str, object]:
    return {"debug": True}

When enabled=False, the decorator silently skips registration — no entry in the device registry and no name slot reserved.

The classic if-guard also works, since app.settings is a plain Python object:

# Classic approach — if-guard
if app.settings.enable_debug_device:
    @app.telemetry("debug", interval=10)
    async def debug_sensor() -> dict[str, object]:
        return {"debug": True}

Accessing Settings in Devices

Settings are available via ctx.settings in both device and telemetry functions:

app.py
@app.telemetry("counter", interval=60)
async def counter(ctx: cosalette.DeviceContext) -> dict[str, object]:
    settings = ctx.settings  # (1)!
    assert isinstance(settings, Gas2MqttSettings)
    meter = ctx.adapter(GasMeterPort)
    return {"impulses": meter.read_impulses()}
  1. The settings instance is the same class you passed to App(settings_class=...). Cast via assert isinstance() for type-safe access to custom fields.
app.py
@app.command("valve")
async def valve(ctx: cosalette.DeviceContext, payload: str) -> dict[str, object]:
    settings = ctx.settings
    assert isinstance(settings, Gas2MqttSettings)
    meter = ctx.adapter(GasMeterPort)
    return {"valve_state": payload}
app.py
@app.device("valve")
async def valve(ctx: cosalette.DeviceContext) -> None:
    settings = ctx.settings
    assert isinstance(settings, Gas2MqttSettings)

    @ctx.on_command
    async def handle(topic: str, payload: str) -> None:
        ...

    while not ctx.shutdown_requested:
        await ctx.sleep(30)

Practical Example: gas2mqtt Settings

A complete, production-ready settings class:

settings.py
"""Settings for gas2mqtt application."""

from __future__ import annotations

from pydantic import Field, SecretStr, field_validator
from pydantic_settings import SettingsConfigDict

import cosalette


class Gas2MqttSettings(cosalette.Settings):
    """Configuration for the gas2mqtt bridge daemon."""

    model_config = SettingsConfigDict(
        env_prefix="GAS2MQTT_",
        env_nested_delimiter="__",
        env_file=".env",
        env_file_encoding="utf-8",
    )

    # Hardware
    serial_port: str = Field(
        default="/dev/ttyUSB0",
        description="Serial port for the gas meter sensor.",
    )
    baud_rate: int = Field(
        default=9600,
        description="Serial baud rate.",
    )

    # Polling
    poll_interval: int = Field(
        default=60,
        ge=1,
        description="Telemetry polling interval in seconds.",
    )

    # Optional cloud reporting
    api_key: SecretStr = Field(
        default=SecretStr(""),
        description="API key for cloud reporting (optional).",
    )

    @field_validator("serial_port")
    @classmethod
    def serial_port_must_be_device(cls, v: str) -> str:
        if not v.startswith("/dev/"):
            msg = f"serial_port must be a /dev/ path, got: {v!r}"
            raise ValueError(msg)
        return v
.env
# gas2mqtt configuration
GAS2MQTT_SERIAL_PORT=/dev/ttyACM0
GAS2MQTT_BAUD_RATE=115200
GAS2MQTT_POLL_INTERVAL=30

# MQTT broker
GAS2MQTT_MQTT__HOST=broker.local
GAS2MQTT_MQTT__USERNAME=gas2mqtt
GAS2MQTT_MQTT__PASSWORD=s3cret

# Logging
GAS2MQTT_LOGGING__LEVEL=INFO
GAS2MQTT_LOGGING__FORMAT=json

See Also

  • Configuration — conceptual overview of the configuration system
  • Logging — logging configuration and formatting
  • ADR-003 — configuration system decisions
  • ADR-004 — logging strategy decisions