diff --git a/docs/contents/configuration.md b/docs/contents/configuration.md index ed1f053..8b90b7a 100644 --- a/docs/contents/configuration.md +++ b/docs/contents/configuration.md @@ -5,18 +5,18 @@ description: "Configuration models and file formats for connectors" The `inorbit-connector` framework uses Pydantic models for configuration, providing validation and type safety. -## ConnectorConfig +## ConnectorRootConfig -The main configuration class is `ConnectorConfig`, which contains all settings for your connector. It includes a `fleet` field containing a list of `RobotConfig` entries. +The main configuration class is `ConnectorRootConfig`, which contains all settings for your connector. It is a `BaseSettings` subclass (from pydantic-settings) that resolves `INORBIT_*` environment variables and reads `config/.env` at instantiation time. It includes a `fleet` field containing a list of `RobotConfig` entries. -Connectors should subclass `inorbit_connector.models.ConnectorConfig` and define a `connector_config` field that contains the configuration for the connector. For more details see the [Creating a Custom Configuration](#creating-a-custom-configuration) section below. +Connectors parametrize `ConnectorRootConfig[T]` with a concrete `ConnectorSpecificConfig` subclass to get typed access to `connector_config`. For more details see the [Creating a Custom Configuration](#creating-a-custom-configuration) section below. ### Key Fields -- **`api_key`** (str | None): The InOrbit API key. Can be set via environment variable `INORBIT_API_KEY` +- **`api_key`** (str | None): The InOrbit API key. Can be set via environment variable `INORBIT_API_KEY`. Required unless `inorbit_robot_key` is provided - **`api_url`** (HttpUrl): The URL of the InOrbit API endpoint. Defaults to InOrbit Cloud SDK URL. Can be set via environment variable `INORBIT_API_URL` - **`connector_type`** (str): A string identifier for your connector type (e.g., "example_bot") -- **`connector_config`** (BaseModel): Your custom configuration model that inherits from Pydantic's `BaseModel`. This is where you define connector-specific fields +- **`connector_config`** (ConnectorSpecificConfig): Your custom configuration model that inherits from `ConnectorSpecificConfig`. Set the `CONNECTOR_TYPE` class variable to get automatic env-var loading with prefix `INORBIT_{CONNECTOR_TYPE}_` - **`update_freq`** (float): Update frequency in Hz for the execution loop. Default is 1.0 - **`location_tz`** (str): The timezone of the robot location (e.g., "America/Los_Angeles", "UTC"). Must be a valid pytz timezone - **`logging`** (LoggingConfig): Logging configuration (see below) @@ -25,16 +25,18 @@ Connectors should subclass `inorbit_connector.models.ConnectorConfig` and define - **`fleet`** (list[RobotConfig]): List of robot configurations (see below) - **`user_scripts_dir`** (DirectoryPath | None): Path to directory containing user scripts for command execution - **`account_id`** (str | None): InOrbit account ID, required for publishing footprints -- **`inorbit_robot_key`** (str | None): Robot key for InOrbit Connect robots. See [API documentation](https://api.inorbit.ai/docs/index.html#operation/generateRobotKey) +- **`inorbit_robot_key`** (str | None): Robot key for InOrbit Connect robots. Required unless `api_key` is provided. See [API documentation](https://api.inorbit.ai/docs/index.html#operation/generateRobotKey) - **`metrics`** (MetricsConfig): Optional Prometheus metrics endpoint. Disabled by default. See [Metrics](usage/metrics) for the full guide and [`MetricsConfig`](#metricsconfig) for the field list. ### Environment Variables -The following environment variables are automatically read during configuration: +`ConnectorRootConfig` is a pydantic-settings `BaseSettings` subclass. Environment variables with the `INORBIT_` prefix are resolved at instantiation time (not import time) and `config/.env` is read automatically. Init kwargs (e.g. from YAML) take precedence over env vars. -- **`INORBIT_API_KEY`** (required): The InOrbit API key +- **`INORBIT_API_KEY`**: The InOrbit API key. Required unless `inorbit_robot_key` is provided - **`INORBIT_API_URL`** (optional): The InOrbit API endpoint URL +When `connector_config` is passed as a dict (e.g. from YAML), the `_env_file` override is forwarded to the nested `ConnectorSpecificConfig` constructor. Passing `_env_file=None` to `ConnectorRootConfig` disables dotenv reading for both root and connector-specific fields. + ## RobotConfig Represents configuration for a single robot in the fleet: @@ -78,29 +80,24 @@ Optional Prometheus metrics endpoint. When `enabled` is `false` (the default) no (creating-a-custom-configuration)= ## Creating a Custom Configuration -To create a connector-specific configuration, subclass `ConnectorConfig`: +Subclass `ConnectorSpecificConfig` for your vendor-specific fields, then parametrize `ConnectorRootConfig` with it directly: ```python -from pydantic import BaseModel -from inorbit_connector.models import ConnectorConfig, RobotConfig +from inorbit_connector.models import ConnectorRootConfig, ConnectorSpecificConfig + +class MyConnectorConfig(ConnectorSpecificConfig): + """Custom fields for your connector.""" + CONNECTOR_TYPE = "my_connector" -class MyRobotConfig(BaseModel): - """Custom fields for your robot.""" api_version: str hardware_revision: str custom_setting: str -class MyConnectorConfig(ConnectorConfig): - """Configuration for your connector.""" - connector_config: MyRobotConfig - - @field_validator("connector_type") - def check_connector_type(cls, connector_type: str) -> str: - if connector_type != "my_connector": - raise ValueError(f"Expected connector type 'my_connector'") - return connector_type +config = ConnectorRootConfig[MyConnectorConfig](**yaml_data) ``` +`ConnectorSpecificConfig` automatically loads environment variables with the prefix `INORBIT_{CONNECTOR_TYPE}_` and reads `config/.env`. For example, with `CONNECTOR_TYPE = "my_connector"`, setting `INORBIT_MY_CONNECTOR_API_VERSION=v2` will populate the `api_version` field. + ## Configuration Files Configuration is typically loaded from YAML files. See: @@ -113,6 +110,5 @@ Use `inorbit_connector.utils.read_yaml()` to load configuration from YAML files: from inorbit_connector.utils import read_yaml yaml_data = read_yaml("config.yaml") -config = MyConnectorConfig(**yaml_data) +config = ConnectorRootConfig[MyConnectorConfig](**yaml_data) ``` - diff --git a/docs/contents/specification/index.md b/docs/contents/specification/index.md index cce19e1..d2e2e97 100644 --- a/docs/contents/specification/index.md +++ b/docs/contents/specification/index.md @@ -68,7 +68,8 @@ The table below lists package-defined symbols meant for direct use (call) or ext | Call | `Connector.publish_pose()` / `publish_map()` | Publish pose/map for the current robot (map handling included). | [Details](connector.md#spec-connector-connector-publishing) | | Call | `Connector.publish_odometry()` / `publish_key_values()` / `publish_system_stats()` | Publish telemetry for the current robot. `publish_system_stats()` defers publishing; defaults published if not called. | [Details](connector.md#spec-connector-connector-publishing) | | Call (advanced) | `Connector._get_session()` | Access the underlying Edge SDK `RobotSession` for the current robot. | [Details](connector.md#spec-connector-connector-get-session) | -| Type | `ConnectorConfig` | Base configuration model for connectors. | [Details](models.md#spec-models-connectorconfig) | +| Type | `ConnectorRootConfig` | Top-level configuration model for connectors (`BaseSettings`). | [Details](models.md#spec-models-connectorrootconfig) | +| Type | `ConnectorSpecificConfig` | Base for per-connector vendor config; derives env prefix from `CONNECTOR_TYPE`. | [Details](models.md#spec-models-connectorspecificconfig) | | Type | `RobotConfig` | Per-robot configuration (robot_id + cameras). | [Details](models.md#spec-models-robotconfig) | | Type | `MapConfig` / `MapConfigTemp` | Map configuration (file-backed vs in-memory bytes) used by map publishing/fetching. | [Details](models.md#spec-models-mapconfig) | | Type | `LoggingConfig` / `LogLevels` | Logging configuration and log-level enum. | [Details](logging.md#spec-logging-loggingconfig) | diff --git a/docs/contents/specification/models.md b/docs/contents/specification/models.md index 9cec305..b2bf2e7 100644 --- a/docs/contents/specification/models.md +++ b/docs/contents/specification/models.md @@ -5,20 +5,42 @@ description: "Configuration models specification" This page specifies the configuration models defined by `inorbit_connector.models`. -(spec-models-connectorconfig)= -## `ConnectorConfig` +(spec-models-connectorrootconfig)= +## `ConnectorRootConfig` -Base configuration model for connectors. +Top-level configuration model for connectors. Subclasses `BaseSettings` from pydantic-settings and is generic over `T: ConnectorSpecificConfig`. Key points: -- You typically **subclass** this to define your connector-specific `connector_config` model. -- The base model reads `INORBIT_API_KEY` and `INORBIT_API_URL` from environment variables by default. +- **Parametrize** with a concrete `ConnectorSpecificConfig` subclass to get typed `connector_config` access: `ConnectorRootConfig[MyConfig](**yaml_data)`. +- Resolves `INORBIT_*` environment variables and reads `config/.env` at instantiation time via pydantic-settings. Init kwargs (typically values from a YAML file) take precedence over env vars. - `fleet` must contain at least one `RobotConfig`, and robot IDs must be unique. +- When `connector_config` arrives as a dict, the model validator constructs it via `__init__` (not `model_validate`) to preserve env-var resolution. The `_env_file` init kwarg is forwarded to the nested constructor for consistent dotenv behavior. -### `to_singular_config(robot_id) -> ConnectorConfig` +### `to_singular_config(robot_id) -> Self` -Returns a config instance of the same subclass type, with `fleet` filtered down to exactly the requested robot. +Returns a config instance of the same type, with `fleet` filtered down to exactly the requested robot. + +(spec-models-connectorspecificconfig)= +## `ConnectorSpecificConfig` + +Base class for per-connector vendor configuration. Subclasses `BaseSettings` from pydantic-settings. + +Subclass this and set the `CONNECTOR_TYPE` class variable. The framework automatically configures env-var loading with the prefix `INORBIT_{CONNECTOR_TYPE}_` and reads `config/.env`. + +```python +from inorbit_connector.models import ConnectorSpecificConfig + +class AcmeConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "acme" + + fleet_host: str + fleet_api_key: str = "default" +``` + +With this definition, `INORBIT_ACME_FLEET_HOST` and `INORBIT_ACME_FLEET_API_KEY` are resolved from the environment. Init kwargs take precedence over env vars. + +Connectors with custom env-loading needs (e.g. per-robot prefixes) can subclass `BaseSettings` directly instead. (spec-models-robotconfig)= ## `RobotConfig` diff --git a/docs/contents/usage/fleet.md b/docs/contents/usage/fleet.md index 18fac22..00e783d 100644 --- a/docs/contents/usage/fleet.md +++ b/docs/contents/usage/fleet.md @@ -8,11 +8,11 @@ Subclass `inorbit_connector.connector.FleetConnector` to manage multiple robots ## Constructor ```python -def __init__(self, config: ConnectorConfig, **kwargs) -> None +def __init__(self, config: ConnectorRootConfig, **kwargs) -> None ``` **Parameters:** -- `config` (ConnectorConfig): The connector configuration containing the fleet +- `config` (ConnectorRootConfig): The connector configuration containing the fleet **Keyword Arguments:** - `register_user_scripts` (bool): Automatically register user scripts. Default: `False` diff --git a/docs/contents/usage/metrics.md b/docs/contents/usage/metrics.md index 56e452f..bd2df17 100644 --- a/docs/contents/usage/metrics.md +++ b/docs/contents/usage/metrics.md @@ -10,7 +10,7 @@ The framework ships an OpenTelemetry-based metrics subsystem that connectors can When `metrics.enabled = true` in your connector configuration, the framework starts a Prometheus HTTP server and exposes: Every framework metric is namespaced by `connector_type` at the source. -With `connector_type="acme"` (set on `ConnectorConfig`), the four +With `connector_type="acme"` (set on `ConnectorRootConfig`), the four framework signals come out as: | Metric | Type | Attributes | Meaning | diff --git a/docs/contents/usage/single-robot.md b/docs/contents/usage/single-robot.md index 24feee3..f028b6c 100644 --- a/docs/contents/usage/single-robot.md +++ b/docs/contents/usage/single-robot.md @@ -8,12 +8,12 @@ Subclass `inorbit_connector.connector.Connector` to create a connector for a sin ## Constructor ```python -def __init__(self, robot_id: str, config: ConnectorConfig, **kwargs) -> None +def __init__(self, robot_id: str, config: ConnectorRootConfig, **kwargs) -> None ``` **Parameters:** - `robot_id` (str): The InOrbit robot ID -- `config` (ConnectorConfig): The connector configuration +- `config` (ConnectorRootConfig): The connector configuration **Keyword Arguments:** - `register_user_scripts` (bool): Automatically register user scripts. Default: `False` diff --git a/examples/fleet-connector/connector.py b/examples/fleet-connector/connector.py index b3c8258..96637c8 100644 --- a/examples/fleet-connector/connector.py +++ b/examples/fleet-connector/connector.py @@ -17,7 +17,8 @@ from inorbit_connector.models import MapConfigTemp # Local -from datatypes import ExampleBotConnectorConfig +from inorbit_connector.models import ConnectorRootConfig +from datatypes import ExampleBotConfig from fleet_client import FleetManager, FleetManagerAPIWrapper # Path to the example map image @@ -38,10 +39,11 @@ class ExampleBotFleetConnector(FleetConnector): Args: robot_ids (list[str]): List of robot IDs in the fleet - config (ExampleBotConnectorConfig): The configuration for the connector + config (ConnectorRootConfig[ExampleBotConfig]): + The configuration for the connector """ - def __init__(self, config: ExampleBotConnectorConfig) -> None: + def __init__(self, config: ConnectorRootConfig[ExampleBotConfig]) -> None: super().__init__(config) # Setup any other initialization things here diff --git a/examples/fleet-connector/datatypes.py b/examples/fleet-connector/datatypes.py index c1cbf21..a22d947 100644 --- a/examples/fleet-connector/datatypes.py +++ b/examples/fleet-connector/datatypes.py @@ -2,59 +2,22 @@ # # SPDX-License-Identifier: MIT -from pydantic import BaseModel, field_validator +from inorbit_connector.models import ConnectorSpecificConfig -from inorbit_connector.models import ConnectorConfig - -CONNECTOR_TYPE = "example_bot" - - -class ExampleBotConfig(BaseModel): +class ExampleBotConfig(ConnectorSpecificConfig): """The configuration for the example bot. This is where you would define and validate additional custom fields for the fleet. Attributes: - example_bot_api_version (str): An example field for the API version of the fleet manager + example_bot_api_version (str): API version of the fleet manager example_bot_hw_rev (str): An example field for the HW revision of the fleet example_bot_custom_value (str): An example field for a custom value of the fleet """ + CONNECTOR_TYPE = "example_bot" + example_bot_api_version: str example_bot_hw_rev: str example_bot_custom_value: str - - -class ExampleBotConnectorConfig(ConnectorConfig): - """The configuration for the example bot fleet connector. - - Each connector should create a class that inherits from ConnectorConfig. - - Attributes: - connector_config (ExampleBotConfig): The config with custom fields for the fleet - """ - - connector_config: ExampleBotConfig - - # noinspection PyMethodParameters - @field_validator("connector_type") - def check_connector_type(cls, connector_type: str) -> str: - """Validate the connector type. - - This should always be equal to the pre-defined constant. - - Args: - connector_type (str): The defined connector type passed in - - Returns: - str: The validated connector type - - Raises: - ValueError: If the connector type is not equal to the pre-defined constant - """ - if connector_type != CONNECTOR_TYPE: - raise ValueError( - f"Expected connector type '{CONNECTOR_TYPE}' not '{connector_type}'" - ) - return connector_type diff --git a/examples/fleet-connector/main.py b/examples/fleet-connector/main.py index dcc1800..1e63511 100644 --- a/examples/fleet-connector/main.py +++ b/examples/fleet-connector/main.py @@ -9,7 +9,8 @@ from inorbit_connector.utils import read_yaml from connector import ExampleBotFleetConnector -from datatypes import ExampleBotConnectorConfig +from inorbit_connector.models import ConnectorRootConfig +from datatypes import ExampleBotConfig """ This is the main entry point for the fleet connector. @@ -29,7 +30,7 @@ def error(self, message): def start(): - """Parses arguments, processes the configuration file and starts the fleet connector.""" + """Parse arguments, process config file and start the fleet connector.""" parser = CustomParser(prog="fleet_connector") parser.add_argument( "-c", @@ -48,7 +49,7 @@ def start(): yaml_data = read_yaml(config_filename) # Create the connector configuration - config = ExampleBotConnectorConfig(**yaml_data) + config = ConnectorRootConfig[ExampleBotConfig](**yaml_data) # Extract robot IDs from the fleet configuration for logging purposes robot_ids = [robot.robot_id for robot in config.fleet] diff --git a/examples/robot-connector/connector.py b/examples/robot-connector/connector.py index da63466..1e32c74 100644 --- a/examples/robot-connector/connector.py +++ b/examples/robot-connector/connector.py @@ -17,7 +17,8 @@ from inorbit_connector.models import MapConfigTemp # Local -from datatypes import ExampleBotConnectorConfig +from inorbit_connector.models import ConnectorRootConfig +from datatypes import ExampleBotConfig from robot import Robot, ExampleBotAPIWrapper # Path to the example map image @@ -36,10 +37,15 @@ class ExampleBotConnector(Connector): Args: robot_id (str): The ID of the InOrbit robot - config (ExampleBotConnectorConfig): The configuration for the connector + config (ConnectorRootConfig[ExampleBotConfig]): + The configuration for the connector """ - def __init__(self, robot_id: str, config: ExampleBotConnectorConfig) -> None: + def __init__( + self, + robot_id: str, + config: ConnectorRootConfig[ExampleBotConfig], + ) -> None: super().__init__(robot_id, config) # Setup any other initialization things here diff --git a/examples/robot-connector/datatypes.py b/examples/robot-connector/datatypes.py index cc9d268..f633291 100644 --- a/examples/robot-connector/datatypes.py +++ b/examples/robot-connector/datatypes.py @@ -2,15 +2,10 @@ # # SPDX-License-Identifier: MIT -from pydantic import BaseModel, field_validator +from inorbit_connector.models import ConnectorSpecificConfig -from inorbit_connector.models import ConnectorConfig - -CONNECTOR_TYPE = "example_bot" - - -class ExampleBotConfig(BaseModel): +class ExampleBotConfig(ConnectorSpecificConfig): """The configuration for the example bot. This is where you would define and validate additional custom fields for the robot. @@ -21,40 +16,8 @@ class ExampleBotConfig(BaseModel): example_bot_custom_value (str): An example field for a custom value of the robot """ + CONNECTOR_TYPE = "example_bot" + example_bot_api_version: str example_bot_hw_rev: str example_bot_custom_value: str - - -class ExampleBotConnectorConfig(ConnectorConfig): - """The configuration for the example bot connector. - - Each connector should create a class that inherits from ConnectorConfig. - - Attributes: - connector_config (ExampleBotConfig): The config with custom fields for the robot - """ - - connector_config: ExampleBotConfig - - # noinspection PyMethodParameters - @field_validator("connector_type") - def check_connector_type(cls, connector_type: str) -> str: - """Validate the connector type. - - This should always be equal to the pre-defined constant. - - Args: - connector_type (str): The defined connector type passed in - - Returns: - str: The validated connector type - - Raises: - ValueError: If the connector type is not equal to the pre-defined constant - """ - if connector_type != CONNECTOR_TYPE: - raise ValueError( - f"Expected connector type '{CONNECTOR_TYPE}' not '{connector_type}'" - ) - return connector_type diff --git a/examples/robot-connector/main.py b/examples/robot-connector/main.py index dda36d1..163275f 100644 --- a/examples/robot-connector/main.py +++ b/examples/robot-connector/main.py @@ -9,7 +9,8 @@ from inorbit_connector.utils import read_yaml from connector import ExampleBotConnector -from datatypes import ExampleBotConnectorConfig +from inorbit_connector.models import ConnectorRootConfig +from datatypes import ExampleBotConfig """ This is the main entry point for the connector. @@ -52,7 +53,7 @@ def start(): try: yaml_data = read_yaml(config_filename) - config = ExampleBotConnectorConfig(**yaml_data) + config = ConnectorRootConfig[ExampleBotConfig](**yaml_data) except FileNotFoundError: LOGGER.error(f"Configuration file '{config_filename}' not found") exit(1) diff --git a/examples/simple-connector/connector.py b/examples/simple-connector/connector.py index 42f6f5e..9aa9fa0 100644 --- a/examples/simple-connector/connector.py +++ b/examples/simple-connector/connector.py @@ -17,21 +17,17 @@ except ImportError: from typing_extensions import override -# Third-party -from pydantic import field_validator, BaseModel - # InOrbit from inorbit_connector.commands import CommandResultCode from inorbit_connector.connector import Connector -from inorbit_connector.models import ConnectorConfig +from inorbit_connector.models import ConnectorRootConfig, ConnectorSpecificConfig from inorbit_connector.utils import read_yaml CONFIG_FILE = Path(__file__).resolve().parent.parent / "example.yaml" # ../example.yaml ROBOT_ID = "my-example-robot" -CONNECTOR_TYPE = "example_bot" -class ExampleBotConfig(BaseModel): +class ExampleBotConfig(ConnectorSpecificConfig): """The configuration for the example bot. This is where you would define and validate additional custom fields for the robot. @@ -42,45 +38,13 @@ class ExampleBotConfig(BaseModel): example_bot_custom_value (str): An example field for a custom value of the robot """ + CONNECTOR_TYPE = "example_bot" + example_bot_api_version: str example_bot_hw_rev: str example_bot_custom_value: str -class ExampleBotConnectorConfig(ConnectorConfig): - """The configuration for the example bot connector. - - Each connector should create a class that inherits from ConnectorConfig. - - Attributes: - connector_config (ExampleBotConfig): The config with custom fields for the robot - """ - - connector_config: ExampleBotConfig - - # noinspection PyMethodParameters - @field_validator("connector_type") - def check_connector_type(cls, connector_type: str) -> str: - """Validate the connector type. - - This should always be equal to the pre-defined constant. - - Args: - connector_type (str): The defined connector type passed in - - Returns: - str: The validated connector type - - Raises: - ValueError: If the connector type is not equal to the pre-defined constant - """ - if connector_type != CONNECTOR_TYPE: - raise ValueError( - f"Expected connector type '{CONNECTOR_TYPE}' not '{connector_type}'" - ) - return connector_type - - async def get_robot_linear_speed() -> float: """Simulate a request to the robot's linear speed API.""" await asyncio.sleep(random.uniform(0.1, 0.3)) @@ -101,10 +65,15 @@ class ExampleBotConnector(Connector): Args: robot_id (str): The ID of the InOrbit robot - config (ExampleBotConnectorConfig): The configuration for the connector + config (ConnectorRootConfig[ExampleBotConfig]): + The configuration for the connector """ - def __init__(self, robot_id: str, config: ExampleBotConnectorConfig) -> None: + def __init__( + self, + robot_id: str, + config: ConnectorRootConfig[ExampleBotConfig], + ) -> None: super().__init__(robot_id, config) # Setup any other initialization things here @@ -201,7 +170,7 @@ def main(): try: yaml_data = read_yaml(CONFIG_FILE) - config = ExampleBotConnectorConfig(**yaml_data) + config = ConnectorRootConfig[ExampleBotConfig](**yaml_data) except FileNotFoundError: logger.error(f"'{CONFIG_FILE}' configuration file does not exist") exit(1) diff --git a/examples/simple-fleet-connector/connector.py b/examples/simple-fleet-connector/connector.py index fa2f328..524a504 100644 --- a/examples/simple-fleet-connector/connector.py +++ b/examples/simple-fleet-connector/connector.py @@ -17,71 +17,35 @@ except ImportError: from typing_extensions import override -# Third-party -from pydantic import field_validator, BaseModel - # InOrbit from inorbit_connector.commands import CommandResultCode from inorbit_connector.connector import FleetConnector -from inorbit_connector.models import ConnectorConfig +from inorbit_connector.models import ConnectorRootConfig, ConnectorSpecificConfig from inorbit_connector.utils import read_yaml CONFIG_FILE = ( Path(__file__).resolve().parent.parent / "example.fleet.yaml" ) # ../example.fleet.yaml -CONNECTOR_TYPE = "example_bot" -class ExampleBotConfig(BaseModel): +class ExampleBotConfig(ConnectorSpecificConfig): """The configuration for the example bot. This is where you would define and validate additional custom fields for the fleet. Attributes: - example_bot_api_version (str): An example field for the API version of the fleet manager + example_bot_api_version (str): API version of the fleet manager example_bot_hw_rev (str): An example field for the HW revision of the fleet example_bot_custom_value (str): An example field for a custom value of the fleet """ + CONNECTOR_TYPE = "example_bot" + example_bot_api_version: str example_bot_hw_rev: str example_bot_custom_value: str -class ExampleBotConnectorConfig(ConnectorConfig): - """The configuration for the example bot connector. - - Each connector should create a class that inherits from ConnectorConfig. - - Attributes: - connector_config (ExampleBotConfig): The config with custom fields for the fleet - """ - - connector_config: ExampleBotConfig - - # noinspection PyMethodParameters - @field_validator("connector_type") - def check_connector_type(cls, connector_type: str) -> str: - """Validate the connector type. - - This should always be equal to the pre-defined constant. - - Args: - connector_type (str): The defined connector type passed in - - Returns: - str: The validated connector type - - Raises: - ValueError: If the connector type is not equal to the pre-defined constant - """ - if connector_type != CONNECTOR_TYPE: - raise ValueError( - f"Expected connector type '{CONNECTOR_TYPE}' not '{connector_type}'" - ) - return connector_type - - async def get_fleet_robot_data(robot_id: str) -> dict: """Simulate a request to the fleet manager's API for a specific robot.""" await asyncio.sleep(random.uniform(0.1, 0.3)) @@ -115,15 +79,17 @@ async def get_fleet_robot_data(robot_id: str) -> dict: class ExampleBotFleetConnector(FleetConnector): """The example bot fleet connector. - This demonstrates how to manage a fleet of robots using the FleetConnector base class. - It simulates fetching data from a fleet manager API and publishing data for multiple robots. + Demonstrates how to manage a fleet of robots using the + FleetConnector base class. Simulates fetching data from a fleet + manager API and publishing data for multiple robots. Args: robot_ids (list[str]): List of robot IDs in the fleet - config (ExampleBotConnectorConfig): The configuration for the connector + config (ConnectorRootConfig[ExampleBotConfig]): + The configuration for the connector """ - def __init__(self, config: ExampleBotConnectorConfig) -> None: + def __init__(self, config: ConnectorRootConfig[ExampleBotConfig]) -> None: super().__init__(config) # Setup any other initialization things here @@ -159,9 +125,9 @@ async def _disconnect(self) -> None: async def _execution_loop(self) -> None: """The main execution loop for the fleet connector. - This demonstrates how to fetch data for multiple robots and publish it to InOrbit. - The key difference from single robot connectors is that we need to specify robot_id - for each publishing operation. + Fetches data for multiple robots and publishes to InOrbit. + Unlike single robot connectors, we specify robot_id for each + publishing operation. """ # Fetch data for all robots concurrently @@ -250,7 +216,7 @@ def main(): yaml_data = read_yaml(CONFIG_FILE) # Create the connector configuration - config = ExampleBotConnectorConfig(**yaml_data) + config = ConnectorRootConfig[ExampleBotConfig](**yaml_data) # Extract robot IDs from the fleet configuration for logging purposes robot_ids = [robot.robot_id for robot in config.fleet] diff --git a/inorbit_connector/connector.py b/inorbit_connector/connector.py index 43b0c0e..7680d8a 100644 --- a/inorbit_connector/connector.py +++ b/inorbit_connector/connector.py @@ -56,7 +56,7 @@ setup_prometheus_metrics, ) from inorbit_connector.models import ( - ConnectorConfig, + ConnectorRootConfig, MapConfig, MapConfigTemp, RobotConfig, @@ -73,11 +73,11 @@ class FleetConnector(ABC): See self.__init__() for more details. """ - def __init__(self, config: ConnectorConfig, **kwargs) -> None: + def __init__(self, config: ConnectorRootConfig, **kwargs) -> None: """Initialize the base connector with common functionality. Args: - config (ConnectorConfig): The connector configuration + config (ConnectorRootConfig): The connector configuration Keyword Args: register_user_scripts (bool): Register user scripts automatically. @@ -908,15 +908,15 @@ class Connector(FleetConnector, ABC): FleetConnector.__init__() for more details. """ - def __init__(self, robot_id: str, config: ConnectorConfig, **kwargs) -> None: + def __init__(self, robot_id: str, config: ConnectorRootConfig, **kwargs) -> None: """Initialize a new InOrbit connector. This class handles bidirectional communication with InOrbit. Args: robot_id (str): The ID of the InOrbit robot - config (ConnectorConfig): The connector configuration. Pass a - `ConnectorConfig` with a `fleet` field containing robot + config (ConnectorRootConfig): The connector configuration. Pass a + `ConnectorRootConfig` with a `fleet` field containing robot configurations. The one for the current robot will be selected by the `robot_id` parameter. diff --git a/inorbit_connector/models.py b/inorbit_connector/models.py index 64875d1..b92dd8d 100644 --- a/inorbit_connector/models.py +++ b/inorbit_connector/models.py @@ -6,10 +6,15 @@ # SPDX-License-Identifier: MIT # Standard -import os import re +from contextvars import ContextVar from pathlib import Path -from typing import List, Optional +from typing import ClassVar, Generic, List, Optional, TypeVar + +try: + from typing import Self +except ImportError: + from typing_extensions import Self # Third-party import pytz @@ -17,12 +22,18 @@ from inorbit_edge.robot import INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL from pydantic import ( BaseModel, - ConfigDict, DirectoryPath, Field, FilePath, HttpUrl, field_validator, + model_validator, +) +from pydantic_settings import ( + BaseSettings, + SettingsConfigDict, + EnvSettingsSource, + DotEnvSettingsSource, ) # InOrbit @@ -197,28 +208,99 @@ class RobotConfig(BaseModel): cameras: List[CameraConfig] = [] -class ConnectorConfig(BaseModel): - """Class representing an Inorbit connector model. +DEFAULT_ENV_FILE = "config/.env" - This should not be instantiated on its own. - A Connector specific configuration should be defined in a subclass adding the - "connector_config" field. +class ConnectorSpecificConfig(BaseSettings): + """Base for per-connector vendor config. - The following environment variables will be read during instantiation: + Subclasses set the CONNECTOR_TYPE class variable to get automatic + env-var loading with prefix ``INORBIT_{CONNECTOR_TYPE}_``. - * INORBIT_API_KEY (required): The InOrbit API key - * INORBIT_API_URL: The URL of the API endpoint or inorbit_edge's - INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL by default + Example:: - in addition to those read by the Edge SDK during connector initialization. + class ExampleBotConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "example_bot" + example_bot_api_version: str + example_bot_hw_rev: str Attributes: - api_key (str | None, optional): The InOrbit API key + CONNECTOR_TYPE (ClassVar[str]): Connector identifier used to derive + the env-var prefix. + """ + + CONNECTOR_TYPE: ClassVar[str] + + # Empty strings (e.g. from YAML placeholders) are ignored so they + # don't override real defaults. Unknown env vars are silently + # discarded (extra="ignore") rather than raising or polluting the + # model. The env_file default is overridable at instantiation via + # the ``_env_file`` kwarg. + model_config = SettingsConfigDict( + env_ignore_empty=True, + case_sensitive=False, + env_file=DEFAULT_ENV_FILE, + extra="ignore", + ) + + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + """Use ``INORBIT_{CONNECTOR_TYPE}_`` as env-var prefix.""" + prefix = f"INORBIT_{cls.CONNECTOR_TYPE.upper()}_" + common = dict(env_prefix=prefix, env_ignore_empty=True, case_sensitive=False) + return ( + init_settings, + EnvSettingsSource(settings_cls, **common), + DotEnvSettingsSource( + settings_cls, env_file=dotenv_settings.env_file, **common + ), + file_secret_settings, + ) + + +T = TypeVar("T", bound=ConnectorSpecificConfig) + +# Thread-safe channel for forwarding ``_env_file`` from +# ConnectorRootConfig.__init__ to its "before" model validator. +_NOT_SET = object() +_env_file_var: ContextVar[object] = ContextVar("_env_file_var", default=_NOT_SET) + + +class ConnectorRootConfig(BaseSettings, Generic[T]): + """Top-level InOrbit connector configuration. + + Reads ``INORBIT_*`` environment variables and ``config/.env`` at + **instantiation time** via pydantic-settings. Init kwargs (typically + loaded from YAML) take precedence over env vars. + + Parametrize with a concrete ``ConnectorSpecificConfig`` subclass to + get typed access to ``connector_config``:: + + config = ConnectorRootConfig[MyConfig](**yaml_data) + + Subclassing is still supported for connectors that need root-level + validators or additional fields. Pass ``_env_file=None`` to disable + dotenv reading for both root and nested config, or an explicit path + to make both read from the same file. + + At least one of ``api_key`` or ``inorbit_robot_key`` must be provided. + If neither is set (via init kwargs, environment variables, or dotenv), + a ``ValidationError`` is raised at instantiation time. + + Attributes: + api_key (str | None, optional): The InOrbit API key. Required unless + ``inorbit_robot_key`` is provided. api_url (HttpUrl, optional): The URL of the API or inorbit_edge's INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL by default - connector_type (str): The type of connector (see Class comment above) - connector_config (BaseModel): The configuration for the connector + connector_type (str): The type of connector + connector_config (ConnectorSpecificConfig): Vendor-specific configuration use_websockets (bool, optional): If True, the underlying edge-sdk ``RobotSession`` connects to the InOrbit MQTT broker over the WebSockets transport instead of the default TCP transport. Combined @@ -233,6 +315,7 @@ class ConnectorConfig(BaseModel): account_id (str | None, optional): InOrbit account id, required for publishing footprints inorbit_robot_key (str | None, optional): Robot key for InOrbit Connect robots. + Required unless ``api_key`` is provided. See https://api.inorbit.ai/docs/index.html#operation/generateRobotKey maps (dict[str, MapConfig], optional): frame_id to map configuration mapping env_vars (dict[str, str], optional): Environment variables to be set in the @@ -241,20 +324,23 @@ class ConnectorConfig(BaseModel): fleet (list[RobotConfig]): The list of robot configurations. """ - model_config = ConfigDict(extra="forbid") + # All fields are resolvable from ``INORBIT_`` env vars or from + # ``config/.env``. Init kwargs (YAML) take highest precedence. + # Unknown ``INORBIT_*`` vars are silently discarded (extra="ignore"). + model_config = SettingsConfigDict( + env_prefix="INORBIT_", + env_ignore_empty=True, + case_sensitive=False, + env_file=DEFAULT_ENV_FILE, + extra="ignore", + ) - api_key: str | None = os.getenv("INORBIT_API_KEY") - # default_factory + explicit HttpUrl construction: handing a bare - # string default to a `HttpUrl`-typed field stores it as `str` (the - # type-coercion validator only runs on explicit inputs, not defaults), - # which then trips Pydantic's serializer warning on every model_dump. + api_key: str | None = None api_url: HttpUrl = Field( - default_factory=lambda: HttpUrl( - os.getenv("INORBIT_API_URL", INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL) - ) + default=HttpUrl(INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL), ) connector_type: str - connector_config: BaseModel + connector_config: T use_websockets: bool = False update_freq: float = 1.0 location_tz: str = DEFAULT_TIMEZONE @@ -267,7 +353,57 @@ class ConnectorConfig(BaseModel): metrics: MetricsConfig = MetricsConfig() fleet: list[RobotConfig] - def to_singular_config(self, robot_id: str) -> "ConnectorConfig": + def __init__(self, **kwargs): + # pydantic-settings consumes _env_file before model validators + # run. Save it in a ContextVar so _instantiate_connector_config + # can forward it to the nested ConnectorSpecificConfig. + token = _env_file_var.set(kwargs.get("_env_file", _NOT_SET)) + try: + super().__init__(**kwargs) + finally: + _env_file_var.reset(token) + + @model_validator(mode="before") + @classmethod + def _instantiate_connector_config(cls, data): + """Construct ``connector_config`` via ``__init__`` when it arrives as + a raw dict so that BaseSettings env-var resolution is triggered.""" + if isinstance(data, dict) and isinstance(data.get("connector_config"), dict): + ann_type = cls.model_fields["connector_config"].annotation + if ( + isinstance(ann_type, type) + and issubclass(ann_type, BaseSettings) + and ann_type not in (BaseSettings, ConnectorSpecificConfig) + ): + env_file_kwargs = {} + env_file = _env_file_var.get() + if env_file is not _NOT_SET: + env_file_kwargs["_env_file"] = env_file + data = { + **data, + "connector_config": ann_type( + **data["connector_config"], **env_file_kwargs + ), + } + return data + + @model_validator(mode="after") + def _require_api_key_or_robot_key(self) -> Self: + """Validate that at least one authentication credential is provided. + + Raises: + ValueError: If neither ``api_key`` nor ``inorbit_robot_key`` is set. + + Returns: + Self: The validated configuration instance. + """ + if self.api_key is None and self.inorbit_robot_key is None: + raise ValueError( + "At least one of 'api_key' or 'inorbit_robot_key' must be provided" + ) + return self + + def to_singular_config(self, robot_id: str) -> Self: """Filters out configurations not related to the given robot. The result is a config with a fleet field of length 1. @@ -275,9 +411,8 @@ def to_singular_config(self, robot_id: str) -> "ConnectorConfig": robot_id (str): The ID of the robot to filter the configuration for Returns: - ConnectorConfig: The filtered configuration (preserves the subclass type) + Self: The filtered configuration """ - # Filter the fleet first to validate robot_id exists filtered_fleet = [robot for robot in self.fleet if robot.robot_id == robot_id] if len(filtered_fleet) != 1: @@ -286,10 +421,9 @@ def to_singular_config(self, robot_id: str) -> "ConnectorConfig": f"got {len(filtered_fleet)}" ) - # Use self.__class__ to preserve the subclass type - # (e.g., ExampleBotConnectorConfig) config = self.__class__( - **self.model_dump(exclude={"fleet"}), + **self.model_dump(exclude={"fleet", "connector_config"}), + connector_config=self.connector_config, fleet=filtered_fleet, ) return config diff --git a/pyproject.toml b/pyproject.toml index 08277c4..e91cae7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ classifiers = [ dependencies = [ "inorbit-edge[telemetry]>=2.1,<3.0", "pydantic>=2.11,<3.0", + "pydantic-settings>=2.14,<3.0", "pytz>=2025.1", "PyYAML>=6.0.2,<7.0", "typing_extensions>=4.5.0; python_version < \"3.12\"", @@ -105,8 +106,8 @@ python = { "3.10" = "py310", "3.11" = "py311", "3.12" = "py312", "3.13" = "py313 [tool.tox.env_run_base] extras = ["dev"] commands = [ - ["flake8", "inorbit_connector"], - ["black", "--check", "--diff", "inorbit_connector"], + ["flake8", "inorbit_connector", "tests", "examples"], + ["black", "--check", "--diff", "inorbit_connector", "tests", "examples"], ["coverage", "run", "-m", "pytest"], ["coverage", "html", "-d", "{envlogdir}/coverage"], ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7efb97d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2025 InOrbit, Inc. +# +# SPDX-License-Identifier: MIT + +"""Shared pytest fixtures for the inorbit-connector test suite.""" + +import os + +import pytest + + +@pytest.fixture(autouse=True) +def _clean_inorbit_env(monkeypatch): + """Remove all ``INORBIT_*`` environment variables before each test.""" + for key in list(os.environ): + if key.startswith("INORBIT_"): + monkeypatch.delenv(key, raising=False) diff --git a/tests/test_commands.py b/tests/test_commands.py index 8cf10c7..b2bbf26 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -16,6 +16,7 @@ parse_custom_command_args, ) + # Test models for ExcludeUnsetMixin class ModelWithMixin(ExcludeUnsetMixin, BaseModel): """Test model using ExcludeUnsetMixin.""" @@ -106,7 +107,7 @@ def test_command_model_validation_error_converted_to_command_failure_in_validate def test_command_model_validation_error_converted_to_command_failure_in_validate_json(): - """Test that ValidationError in model_validate_json is converted to CommandFailure.""" + """Test ValidationError in model_validate_json converts to CommandFailure.""" with pytest.raises(CommandFailure): SimpleCommand.model_validate_json( '{"command_id": "test", "priority": "not_an_int"}' @@ -264,7 +265,7 @@ def test_command_model_works_with_parse_custom_command_args(): def test_command_model_invalid_args_from_parse_custom_command_args(): - """Test that invalid arguments from parse_custom_command_args raise CommandFailure.""" + """Test invalid args from parse_custom_command_args raise CommandFailure.""" script_name, script_args = parse_custom_command_args( ["queue_mission", ["command_id", "test123", "priority", "not_an_int"]] ) diff --git a/tests/test_connector.py b/tests/test_connector.py index 74e2b97..060d554 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -12,7 +12,7 @@ # Third-party import pytest -from pydantic import AnyHttpUrl, BaseModel +from pydantic import AnyHttpUrl from inorbit_edge.robot import RobotSession # InOrbit @@ -23,13 +23,14 @@ FleetConnector, ) from inorbit_connector.models import ( - ConnectorConfig, + ConnectorRootConfig, + ConnectorSpecificConfig, RobotConfig, ) -class DummyConfig(BaseModel): - pass +class DummyConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "dummy" # ============================================================================== @@ -116,7 +117,7 @@ async def _inorbit_robot_command_handler( pass connector = SubFleetConnector( - ConnectorConfig( + ConnectorRootConfig( api_key="valid_key", api_url="https://valid.com/", connector_type="valid_connector", @@ -148,7 +149,7 @@ def make_fleet_connector_not_abstract(self): @pytest.fixture def base_fleet_connector(self, base_model): return FleetConnector( - ConnectorConfig( + ConnectorRootConfig( **base_model, fleet=[ RobotConfig(robot_id="TestRobot1"), @@ -158,7 +159,7 @@ def base_fleet_connector(self, base_model): ) def test_init(self, base_model): - config = ConnectorConfig( + config = ConnectorRootConfig( **base_model, fleet=[ RobotConfig(robot_id="TestRobot1"), @@ -173,7 +174,7 @@ def test_init(self, base_model): assert connector._logger.name == FleetConnector.__module__ def test_init_with_robot_key(self, base_model): - config = ConnectorConfig( + config = ConnectorRootConfig( **base_model, inorbit_robot_key="valid_robot_key", fleet=[ @@ -216,7 +217,7 @@ def test_use_websockets_propagates_to_factory(self, base_model): """When use_websockets=True is set on the config, it must reach the RobotSessionFactory so the edge-sdk RobotSession picks the websockets (wss when use_ssl is on) transport.""" - config = ConnectorConfig( + config = ConnectorRootConfig( **base_model, use_websockets=True, fleet=[ @@ -252,7 +253,7 @@ def test_publish_robot_pose_updates_map(self, base_model, mock_robot_session_poo } } connector = FleetConnector( - ConnectorConfig( + ConnectorRootConfig( **base_model, fleet=[RobotConfig(robot_id="TestRobot1")], ) @@ -341,7 +342,7 @@ async def test_register_user_scripts( """Test user scripts registration for fleet connector.""" # Create a connector with user scripts enabled connector = FleetConnector( - ConnectorConfig( + ConnectorRootConfig( **base_model, fleet=[ RobotConfig(robot_id="TestRobot1"), @@ -365,7 +366,7 @@ async def test_register_user_scripts( def test_uses_env_vars(self, base_model): base_model["env_vars"] = {"FLEET_ENV_VAR": "fleet_value"} FleetConnector( - ConnectorConfig( + ConnectorRootConfig( **base_model, fleet=[RobotConfig(robot_id="TestRobot1")], ) @@ -393,19 +394,16 @@ def make_fleet_connector_not_abstract(self): @pytest.fixture def fleet_connector(self, base_model): return FleetConnector( - ConnectorConfig( + ConnectorRootConfig( **base_model, fleet=[RobotConfig(robot_id="TestRobot1")], ) ) - def test_fetch_robot_map_default_returns_none(self, fleet_connector): + @pytest.mark.asyncio + async def test_fetch_robot_map_default_returns_none(self, fleet_connector): """Test that default fetch_robot_map returns None.""" - import asyncio - - result = asyncio.get_event_loop().run_until_complete( - fleet_connector.fetch_robot_map("TestRobot1", "frame1") - ) + result = await fleet_connector.fetch_robot_map("TestRobot1", "frame1") assert result is None def test_publish_robot_map_schedules_fetch_when_map_not_found( @@ -575,7 +573,7 @@ def make_fleet_connector_not_abstract(self): @pytest.fixture def fleet_connector(self, base_model): return FleetConnector( - ConnectorConfig( + ConnectorRootConfig( **base_model, fleet=[ RobotConfig(robot_id="TestRobot1"), @@ -685,7 +683,7 @@ def test_publish_connector_system_stats_uses_psutil( mock_psutil.disk_usage.return_value.percent = 70.0 connector = FleetConnector( - ConnectorConfig( + ConnectorRootConfig( **base_model, fleet=[RobotConfig(robot_id="TestRobot1")], ), @@ -707,7 +705,7 @@ def test_publish_connector_system_stats_fallback_without_psutil( """Test fallback to zeroed defaults when psutil not available.""" with patch("inorbit_connector.connector.PSUTIL_AVAILABLE", False): connector = FleetConnector( - ConnectorConfig( + ConnectorRootConfig( **base_model, fleet=[RobotConfig(robot_id="TestRobot1")], ), @@ -737,7 +735,7 @@ def test_publish_connector_system_stats_logs_warning_without_psutil( mock_logging.getLogger.return_value = mock_logger FleetConnector( - ConnectorConfig( + ConnectorRootConfig( **base_model, fleet=[RobotConfig(robot_id="TestRobot1")], ), @@ -800,7 +798,7 @@ async def _inorbit_command_handler(self, command_name, args, options): connector = SubConnector( "TestRobot", - ConnectorConfig( + ConnectorRootConfig( api_key="valid_key", api_url="https://valid.com/", connector_type="valid_connector", @@ -831,13 +829,15 @@ def make_connector_not_abstract(self): def base_connector(self, base_model): return Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) def test_init(self, base_model): """Test Connector initialization.""" robot_id = "TestRobot" - config = ConnectorConfig( + config = ConnectorRootConfig( **base_model, fleet=[RobotConfig(robot_id=robot_id)], ) @@ -845,14 +845,14 @@ def test_init(self, base_model): connector = Connector(robot_id, config) assert connector.robot_id == robot_id assert connector.robot_ids == [robot_id] - assert isinstance(connector.config, ConnectorConfig) + assert isinstance(connector.config, ConnectorRootConfig) assert len(connector.config.fleet) == 1 assert connector.config.fleet[0].robot_id == robot_id assert connector._logger.name == Connector.__module__ def test_init_with_robot_key(self, base_model, mock_robot_session_pool): """Test initialization with robot key.""" - config = ConnectorConfig( + config = ConnectorRootConfig( **base_model, inorbit_robot_key="valid_robot_key", fleet=[RobotConfig(robot_id="TestRobot")], @@ -884,7 +884,9 @@ def test_publish_map(self, base_model, mock_robot_session_pool): } connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) connector.publish_map("frameA") @@ -906,7 +908,9 @@ def test_publish_pose(self, base_model, mock_robot_session_pool): } connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) connector.publish_pose(1.0, 2.0, 3.14, "frameA") @@ -935,7 +939,9 @@ def test_publish_pose_updates_maps(self, base_model, mock_robot_session_pool): } connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) session = connector._get_session() @@ -993,7 +999,7 @@ def test_is_fleet_robot_online_delegates_to_is_robot_online(self, base_connector @pytest.mark.asyncio async def test_inorbit_robot_command_handler_delegates(self, base_connector): - """Test that _inorbit_robot_command_handler delegates to _inorbit_command_handler.""" + """Test _inorbit_robot_command_handler delegates to _inorbit_command_handler.""" base_connector._inorbit_command_handler = AsyncMock() await base_connector._inorbit_robot_command_handler( @@ -1012,7 +1018,9 @@ async def test_register_user_scripts( # Create a connector with user scripts enabled connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), register_user_scripts=True, default_user_scripts_dir=tmp_path, ) @@ -1030,7 +1038,9 @@ def test_uses_env_vars(self, base_model): base_model["env_vars"] = {"ENV_VAR": "env_value"} Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) assert "ENV_VAR" in os.environ assert os.environ["ENV_VAR"] == "env_value" @@ -1040,7 +1050,9 @@ async def test_start_stop_integration(self, base_model): """Integration test for start/stop functionality.""" connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) connector._execution_loop = AsyncMock() connector._connect = AsyncMock() @@ -1099,7 +1111,9 @@ def make_connector_not_abstract(self): def base_connector(self, base_model): return Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) @pytest.mark.asyncio @@ -1109,7 +1123,9 @@ async def test_register_command_handler_by_default( """Test that command handler is registered by default.""" connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) connector._connect = AsyncMock() @@ -1127,7 +1143,9 @@ async def test_does_not_register_when_disabled( """Test that command handler is not registered when disabled.""" connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), register_custom_command_handler=False, ) connector._connect = AsyncMock() @@ -1146,7 +1164,9 @@ async def test_sets_online_status_callback( """Test that online status callback is set on EdgeSDK.""" connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) connector._connect = AsyncMock() @@ -1164,10 +1184,12 @@ async def test_sets_online_status_callback( def test_handle_command_exception_with_command_failure( self, base_model, mock_robot_session_pool ): - """Test that CommandFailure exceptions are properly handled and passed to result_function.""" + """Test CommandFailure exceptions are handled and passed to result_function.""" connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) result_function = MagicMock() @@ -1193,10 +1215,12 @@ def test_handle_command_exception_with_command_failure( def test_handle_command_exception_with_generic_exception( self, base_model, mock_robot_session_pool ): - """Test that generic exceptions are handled and passed to result_function with generic message.""" + """Test generic exceptions are handled and passed to result_function.""" connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) result_function = MagicMock() @@ -1222,7 +1246,9 @@ def test_handle_command_exception_without_message( """Test that exceptions without a message use the class name as stderr.""" connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) result_function = MagicMock() diff --git a/tests/test_connector_metrics.py b/tests/test_connector_metrics.py index 47319df..e911836 100644 --- a/tests/test_connector_metrics.py +++ b/tests/test_connector_metrics.py @@ -5,19 +5,19 @@ import urllib.request import pytest -from pydantic import BaseModel from opentelemetry.metrics import _internal as otel_metrics_internal from inorbit_connector.connector import FleetConnector from inorbit_connector.models import ( - ConnectorConfig, + ConnectorRootConfig, + ConnectorSpecificConfig, MetricsConfig, RobotConfig, ) -class _MinimalConnectorConfig(BaseModel): - pass +class _MinimalConnectorConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "minimal" class _MinimalConnector(FleetConnector): @@ -52,7 +52,7 @@ def _make_config(tmp_path, **overrides): ), ) base.update(overrides) - return ConnectorConfig(**base) + return ConnectorRootConfig(**base) @pytest.fixture(autouse=True) @@ -71,8 +71,7 @@ def _reset_otel_global(): def patched_run_connector(monkeypatch): """Replace the connector run thread target with a no-op.""" monkeypatch.setattr( - "inorbit_connector.connector.FleetConnector." - "_FleetConnector__run_connector", + "inorbit_connector.connector.FleetConnector." "_FleetConnector__run_connector", lambda self: None, ) @@ -98,7 +97,7 @@ def test_metrics_server_lifecycle(tmp_path, patched_run_connector): def test_metrics_disabled_by_default(tmp_path, patched_run_connector): - cfg = ConnectorConfig( + cfg = ConnectorRootConfig( api_key="ak", connector_type="test", connector_config=_MinimalConnectorConfig(), diff --git a/tests/test_metrics_server.py b/tests/test_metrics_server.py index c0b4178..5df9660 100644 --- a/tests/test_metrics_server.py +++ b/tests/test_metrics_server.py @@ -93,9 +93,7 @@ def test_server_handles_port_bind_failure(metrics_enabled, tmp_path, caplog): server.start() assert server.actual_port is None assert not (tmp_path / "test-1.json").exists() - assert any( - "bind" in r.getMessage().lower() for r in caplog.records - ) + assert any("bind" in r.getMessage().lower() for r in caplog.records) server.stop() finally: sock.close() diff --git a/tests/test_models.py b/tests/test_models.py index ea145db..313b26c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,34 +6,29 @@ # SPDX-License-Identifier: MIT # Standard -import importlib import os -import re -import sys -from unittest import mock # Third-party import pytest from inorbit_edge.models import CameraConfig from inorbit_edge.robot import INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL -from pydantic import ValidationError, BaseModel +from pydantic import ValidationError # InOrbit from inorbit_connector.models import ( - ConnectorConfig, + ConnectorRootConfig, + ConnectorSpecificConfig, MapConfig, MapConfigBase, MapConfigTemp, MetricsConfig, RobotConfig, - LoggingConfig, ) from pathlib import Path -from inorbit_connector.logging.logger import LogLevels -class DummyConfig(BaseModel): - pass +class DummyConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "dummy" class InvalidDummyConfig(IndexError): @@ -56,7 +51,7 @@ def test_with_cameras(self): assert str(robot_config.cameras[0].video_url) == "https://test.com/" -class TestConnectorConfig: +class TestConnectorRootConfig: @pytest.fixture def base_model(self): return { @@ -71,7 +66,7 @@ def base_model(self): } def test_with_valid_input(self, base_model): - model = ConnectorConfig(**base_model) + model = ConnectorRootConfig(**base_model, _env_file=None) assert model.api_key == base_model["api_key"] assert str(model.api_url) == base_model["api_url"] assert model.connector_type == base_model["connector_type"] @@ -86,7 +81,7 @@ def test_fleet_must_contain_at_least_one_robot(self, base_model): with pytest.raises( ValidationError, match="Fleet must contain at least one robot" ): - ConnectorConfig(**init_input) + ConnectorRootConfig(**init_input, _env_file=None) def test_robot_ids_must_be_unique(self, base_model): init_input = base_model.copy() @@ -95,7 +90,7 @@ def test_robot_ids_must_be_unique(self, base_model): {"robot_id": "robot1"}, ] with pytest.raises(ValidationError, match="Robot ids must be unique"): - ConnectorConfig(**init_input) + ConnectorRootConfig(**init_input, _env_file=None) def test_with_robot_cameras(self, base_model): init_input = base_model.copy() @@ -105,12 +100,12 @@ def test_with_robot_cameras(self, base_model): "cameras": [CameraConfig(video_url="https://test.com/")], }, ] - model = ConnectorConfig(**init_input) + model = ConnectorRootConfig(**init_input, _env_file=None) assert len(model.fleet[0].cameras) == 1 assert str(model.fleet[0].cameras[0].video_url) == "https://test.com/" def test_to_singular_config(self, base_model): - model = ConnectorConfig(**base_model) + model = ConnectorRootConfig(**base_model, _env_file=None) singular = model.to_singular_config("robot1") assert len(singular.fleet) == 1 assert singular.fleet[0].robot_id == "robot1" @@ -118,7 +113,7 @@ def test_to_singular_config(self, base_model): assert singular.api_key == model.api_key def test_to_singular_config_invalid_robot_id(self, base_model): - model = ConnectorConfig(**base_model) + model = ConnectorRootConfig(**base_model, _env_file=None) with pytest.raises( ValueError, match="Expected 1 robot configuration for robot invalid_robot, got 0", @@ -126,94 +121,315 @@ def test_to_singular_config_invalid_robot_id(self, base_model): model.to_singular_config("invalid_robot") def test_to_singular_config_preserves_subclass_type(self, base_model): - class CustomConnectorConfig(ConnectorConfig): + class CustomConnectorConfig(ConnectorRootConfig): pass - model = CustomConnectorConfig(**base_model) + model = CustomConnectorConfig(**base_model, _env_file=None) singular = model.to_singular_config("robot1") assert isinstance(singular, CustomConnectorConfig) def test_use_websockets_defaults_to_false(self, base_model): - model = ConnectorConfig(**base_model) + model = ConnectorRootConfig(**base_model, _env_file=None) assert model.use_websockets is False def test_use_websockets_can_be_enabled(self, base_model): init_input = base_model.copy() init_input["use_websockets"] = True - model = ConnectorConfig(**init_input) + model = ConnectorRootConfig(**init_input, _env_file=None) assert model.use_websockets is True def test_use_websockets_must_be_bool(self, base_model): init_input = base_model.copy() init_input["use_websockets"] = "not-a-bool" with pytest.raises(ValidationError): - ConnectorConfig(**init_input) + ConnectorRootConfig(**init_input, _env_file=None) def test_use_websockets_preserved_in_to_singular_config(self, base_model): init_input = base_model.copy() init_input["use_websockets"] = True - model = ConnectorConfig(**init_input) + model = ConnectorRootConfig(**init_input, _env_file=None) singular = model.to_singular_config("robot1") assert singular.use_websockets is True - def test_extra_fields_are_forbidden(self, base_model): + def test_extra_fields_are_ignored(self, base_model): init_input = base_model.copy() init_input["log_level"] = "INFO" - with pytest.raises(ValidationError, match="Extra inputs are not permitted"): - ConnectorConfig(**init_input) + model = ConnectorRootConfig(**init_input, _env_file=None) + assert not hasattr(model, "log_level") + + def test_reads_api_key_from_env(self, monkeypatch): + monkeypatch.setenv("INORBIT_API_KEY", "env_valid_key") + model = ConnectorRootConfig( + connector_type="valid_connector", + connector_config=DummyConfig(), + fleet=[{"robot_id": "robot1"}], + _env_file=None, + ) + assert model.api_key == "env_valid_key" - @mock.patch.dict(os.environ, {"INORBIT_API_KEY": "env_valid_key"}) - def test_reads_api_key_from_environment_variable(self, base_model): - importlib.reload(sys.modules["inorbit_connector.models"]) - from inorbit_connector.models import ConnectorConfig as ReloadedConfig + def test_reads_api_url_from_env(self, monkeypatch): + monkeypatch.setenv("INORBIT_API_URL", "https://valid.env/") + model = ConnectorRootConfig( + api_key="valid_key", + connector_type="valid_connector", + connector_config=DummyConfig(), + fleet=[{"robot_id": "robot1"}], + _env_file=None, + ) + assert str(model.api_url) == "https://valid.env/" - init_input = { - "api_url": "https://valid.video_url/", - "connector_type": "valid_connector", - "connector_config": DummyConfig(), - "fleet": [{"robot_id": "robot1"}], - } - model = ReloadedConfig(**init_input) - assert model.api_key == "env_valid_key" + def test_api_url_default_when_no_env(self): + model = ConnectorRootConfig( + api_key="valid_key", + connector_type="valid_connector", + connector_config=DummyConfig(), + fleet=[{"robot_id": "robot1"}], + _env_file=None, + ) + assert str(model.api_url) == INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL - @mock.patch.dict(os.environ, {"INORBIT_API_URL": "https://valid.env/"}) - def test_reads_api_url_from_environment_variable(self, base_model): - importlib.reload(sys.modules["inorbit_connector.models"]) - from inorbit_connector.models import ConnectorConfig as ReloadedConfig + def test_api_key_none_with_robot_key(self): + """api_key can be None when inorbit_robot_key provides authentication.""" + model = ConnectorRootConfig( + inorbit_robot_key="valid_robot_key", + connector_type="valid_connector", + connector_config=DummyConfig(), + fleet=[{"robot_id": "robot1"}], + _env_file=None, + ) + assert model.api_key is None + assert model.inorbit_robot_key == "valid_robot_key" + + def test_init_kwargs_override_env(self, monkeypatch): + monkeypatch.setenv("INORBIT_API_KEY", "env_key") + model = ConnectorRootConfig( + api_key="yaml_key", + connector_type="valid_connector", + connector_config=DummyConfig(), + fleet=[{"robot_id": "robot1"}], + _env_file=None, + ) + assert model.api_key == "yaml_key" + + def test_reads_from_env_file(self, tmp_path): + env_file = tmp_path / ".env" + env_file.write_text("INORBIT_API_KEY=from_dotenv\n") + model = ConnectorRootConfig( + connector_type="valid_connector", + connector_config=DummyConfig(), + fleet=[{"robot_id": "robot1"}], + _env_file=str(env_file), + ) + assert model.api_key == "from_dotenv" - init_input = { - "connector_type": "valid_connector", - "connector_config": DummyConfig(), - "fleet": [{"robot_id": "robot1"}], - } - model = ReloadedConfig(**init_input) - assert str(model.api_url) == "https://valid.env/" + def test_requires_api_key_or_robot_key(self, base_model): + init_input = base_model.copy() + del init_input["api_key"] + with pytest.raises( + ValidationError, + match="At least one of 'api_key' or 'inorbit_robot_key' must be provided", + ): + ConnectorRootConfig(**init_input, _env_file=None) - @mock.patch.dict(os.environ, {}, clear=True) - def test_reads_api_url_from_environment_variable_default(self, base_model): - importlib.reload(sys.modules["inorbit_connector.models"]) - from inorbit_connector.models import ConnectorConfig as ReloadedConfig + def test_accepts_only_inorbit_robot_key(self, base_model): + init_input = base_model.copy() + del init_input["api_key"] + init_input["inorbit_robot_key"] = "valid_robot_key" + model = ConnectorRootConfig(**init_input, _env_file=None) + assert model.api_key is None + assert model.inorbit_robot_key == "valid_robot_key" - init_input = { - "connector_type": "valid_connector", - "connector_config": DummyConfig(), - "fleet": [{"robot_id": "robot1"}], - } - model = ReloadedConfig(**init_input) - assert str(model.api_url) == INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL + def test_accepts_both_api_key_and_robot_key(self, base_model): + init_input = base_model.copy() + init_input["inorbit_robot_key"] = "valid_robot_key" + model = ConnectorRootConfig(**init_input, _env_file=None) + assert model.api_key == "valid_key" + assert model.inorbit_robot_key == "valid_robot_key" + + def test_connector_config_env_resolves_through_root(self, monkeypatch): + """Env vars with connector-specific prefix resolve when connector_config + is passed as a dict (simulating YAML loading).""" + + class FieldedConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + class RootWithFielded(ConnectorRootConfig): + connector_config: FieldedConfig + + monkeypatch.setenv("INORBIT_TEST_BOT_SOME_FIELD", "from_env") + model = RootWithFielded( + api_key="ak", + connector_type="test_bot", + connector_config={}, + fleet=[{"robot_id": "r1"}], + _env_file=None, + ) + assert model.connector_config.some_field == "from_env" - @mock.patch.dict(os.environ, {}, clear=True) - def test_missing_api_key_environment_variable(self, base_model): - importlib.reload(sys.modules["inorbit_connector.models"]) - from inorbit_connector.models import ConnectorConfig as ReloadedConfig + def test_connector_config_yaml_overrides_env(self, monkeypatch): + """Init kwargs (YAML) for connector_config fields take precedence over env.""" - init_input = { - "connector_type": "valid_connector", - "connector_config": DummyConfig(), - "fleet": [{"robot_id": "robot1"}], + class FieldedConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + class RootWithFielded(ConnectorRootConfig): + connector_config: FieldedConfig + + monkeypatch.setenv("INORBIT_TEST_BOT_SOME_FIELD", "from_env") + model = RootWithFielded( + api_key="ak", + connector_type="test_bot", + connector_config={"some_field": "from_yaml"}, + fleet=[{"robot_id": "r1"}], + _env_file=None, + ) + assert model.connector_config.some_field == "from_yaml" + + +class TestConnectorRootConfigGeneric: + """Tests for the generic ConnectorRootConfig[T] parametrization.""" + + @pytest.fixture + def base_kwargs(self): + return { + "api_key": "valid_key", + "connector_type": "dummy", + "fleet": [{"robot_id": "robot1"}, {"robot_id": "robot2"}], + "_env_file": None, } - model = ReloadedConfig(**init_input) - assert model.api_key is None + + def test_generic_basic_construction(self, base_kwargs): + config = ConnectorRootConfig[DummyConfig]( + **base_kwargs, + connector_config=DummyConfig(), + ) + assert isinstance(config.connector_config, DummyConfig) + assert config.api_key == "valid_key" + + def test_generic_annotation_resolves_to_concrete_type(self): + parametrized = ConnectorRootConfig[DummyConfig] + ann = parametrized.model_fields["connector_config"].annotation + assert ann is DummyConfig + + def test_unparametrized_annotation_is_typevar(self): + ann = ConnectorRootConfig.model_fields["connector_config"].annotation + assert not isinstance(ann, type) + + def test_generic_dict_connector_config_triggers_model_validator(self, base_kwargs): + """Dict passed as connector_config is instantiated via model validator.""" + + class FieldedConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + config = ConnectorRootConfig[FieldedConfig]( + **{**base_kwargs, "connector_type": "test_bot"}, + connector_config={"some_field": "from_dict"}, + ) + assert isinstance(config.connector_config, FieldedConfig) + assert config.connector_config.some_field == "from_dict" + + def test_generic_env_var_resolution(self, monkeypatch, base_kwargs): + """Env vars resolve through generic parametrization (no subclass needed).""" + + class FieldedConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + monkeypatch.setenv("INORBIT_TEST_BOT_SOME_FIELD", "from_env") + config = ConnectorRootConfig[FieldedConfig]( + **{**base_kwargs, "connector_type": "test_bot"}, + connector_config={}, + ) + assert config.connector_config.some_field == "from_env" + + def test_generic_yaml_overrides_env(self, monkeypatch, base_kwargs): + """Init kwargs (YAML) take precedence over env for generic parametrization.""" + + class FieldedConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + monkeypatch.setenv("INORBIT_TEST_BOT_SOME_FIELD", "from_env") + config = ConnectorRootConfig[FieldedConfig]( + **{**base_kwargs, "connector_type": "test_bot"}, + connector_config={"some_field": "from_yaml"}, + ) + assert config.connector_config.some_field == "from_yaml" + + def test_to_singular_config_preserves_generic_type(self, base_kwargs): + config = ConnectorRootConfig[DummyConfig]( + **base_kwargs, + connector_config=DummyConfig(), + ) + singular = config.to_singular_config("robot1") + assert len(singular.fleet) == 1 + assert isinstance(singular.connector_config, DummyConfig) + assert type(singular) is type(config) + + def test_generic_env_file_forwards_to_connector_config(self, tmp_path): + """_env_file passed to root is forwarded to nested connector_config.""" + + class FieldedConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + env_file = tmp_path / ".env" + env_file.write_text("INORBIT_TEST_BOT_SOME_FIELD=from_dotenv\n") + config = ConnectorRootConfig[FieldedConfig]( + api_key="ak", + connector_type="test_bot", + connector_config={}, + fleet=[{"robot_id": "r1"}], + _env_file=str(env_file), + ) + assert config.connector_config.some_field == "from_dotenv" + + def test_generic_env_file_none_prevents_connector_config_dotenv( + self, tmp_path, monkeypatch + ): + """_env_file=None on root prevents nested config from reading dotenv.""" + + class FieldedConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + monkeypatch.chdir(tmp_path) + (tmp_path / "config").mkdir() + (tmp_path / "config" / ".env").write_text( + "INORBIT_TEST_BOT_SOME_FIELD=from_dotenv\n" + ) + config = ConnectorRootConfig[FieldedConfig]( + api_key="ak", + connector_type="test_bot", + connector_config={}, + fleet=[{"robot_id": "r1"}], + _env_file=None, + ) + assert config.connector_config.some_field == "default" + + def test_subclass_env_file_forwards_to_connector_config(self, tmp_path): + """_env_file forwarding works with the subclass pattern too.""" + + class FieldedConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + class RootWithFielded(ConnectorRootConfig): + connector_config: FieldedConfig + + env_file = tmp_path / ".env" + env_file.write_text("INORBIT_TEST_BOT_SOME_FIELD=from_dotenv\n") + config = RootWithFielded( + api_key="ak", + connector_type="test_bot", + connector_config={}, + fleet=[{"robot_id": "r1"}], + _env_file=str(env_file), + ) + assert config.connector_config.some_field == "from_dotenv" class TestMapConfigBase: @@ -390,8 +606,6 @@ def test_exporter_namespace_accepts_underscores_and_digits(self): assert cfg.exporter_namespace == "inorbit_connector_v2" def test_exporter_namespace_accepts_none_for_auto_derive(self): - # None means setup_prometheus_metrics derives - # `inorbit__connector` at install time. cfg = MetricsConfig(exporter_namespace=None) assert cfg.exporter_namespace is None @@ -404,9 +618,7 @@ def test_extra_resource_attributes_rejects_invalid_keys(self): MetricsConfig(extra_resource_attributes={"has-hyphen": "ok"}) def test_extra_resource_attributes_accepts_valid_pairs(self): - cfg = MetricsConfig( - extra_resource_attributes={"site": "lab", "region": "us"} - ) + cfg = MetricsConfig(extra_resource_attributes={"site": "lab", "region": "us"}) assert cfg.extra_resource_attributes == {"site": "lab", "region": "us"} def test_discovery_dir_accepts_none(self): @@ -415,12 +627,78 @@ def test_discovery_dir_accepts_none(self): assert cfg.discovery_dir is None +class TestConnectorSpecificConfig: + def test_env_prefix_derived_from_connector_type(self, monkeypatch): + class TestConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + monkeypatch.setenv("INORBIT_TEST_BOT_SOME_FIELD", "from_env") + config = TestConfig(_env_file=None) + assert config.some_field == "from_env" + + def test_init_kwargs_override_env(self, monkeypatch): + class TestConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + monkeypatch.setenv("INORBIT_TEST_BOT_SOME_FIELD", "from_env") + config = TestConfig(some_field="from_yaml", _env_file=None) + assert config.some_field == "from_yaml" + + def test_field_default_when_no_env(self): + class TestConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + config = TestConfig(_env_file=None) + assert config.some_field == "default" + + def test_env_prefix_uses_uppercase_connector_type(self, monkeypatch): + class TestConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "my_bot" + host: str = "localhost" + + monkeypatch.setenv("INORBIT_MY_BOT_HOST", "192.168.1.1") + config = TestConfig(_env_file=None) + assert config.host == "192.168.1.1" + + def test_unprefixed_env_var_ignored(self, monkeypatch): + class TestConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + monkeypatch.setenv("SOME_FIELD", "should_not_match") + config = TestConfig(_env_file=None) + assert config.some_field == "default" + + def test_dotenv_file_with_prefix(self, tmp_path): + class TestConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + env_file = tmp_path / ".env" + env_file.write_text("INORBIT_TEST_BOT_SOME_FIELD=from_dotenv\n") + config = TestConfig(_env_file=str(env_file)) + assert config.some_field == "from_dotenv" + + def test_env_ignore_empty(self, monkeypatch): + class TestConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" + + monkeypatch.setenv("INORBIT_TEST_BOT_SOME_FIELD", "") + config = TestConfig(_env_file=None) + assert config.some_field == "default" + + def test_connector_config_includes_metrics_with_default(): - cfg = ConnectorConfig( + cfg = ConnectorRootConfig( api_key="ak", connector_type="test", connector_config=DummyConfig(), fleet=[{"robot_id": "r1"}], + _env_file=None, ) assert isinstance(cfg.metrics, MetricsConfig) assert cfg.metrics.enabled is False diff --git a/tests/test_utils.py b/tests/test_utils.py index ec3bac7..159204a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -44,7 +44,6 @@ def test_read_yaml_returns_entire_file(_): assert result == expected - @mock.patch("builtins.open", new_callable=mock.mock_open, read_data="") def test_read_yaml_returns_empty_dict_when_file_empty(_): result = utils.read_yaml("dummy.yaml")