From 2e6ce489744d29dfd46cf596e847415d747434e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= Date: Mon, 25 May 2026 22:48:14 -0300 Subject: [PATCH 01/10] Migrate ConnectorConfig to BaseSettings and add ConnectorSpecificConfig Rename ConnectorConfig to ConnectorRootConfig, rebase from BaseModel to pydantic-settings BaseSettings. Add ConnectorSpecificConfig base class for per-connector settings with automatic INORBIT_{CONNECTOR_TYPE}_ env prefix. Env vars now resolve at instantiation time instead of import time, eliminating load_dotenv() workarounds. Co-Authored-By: Claude Opus 4.6 --- examples/fleet-connector/datatypes.py | 34 +-- examples/robot-connector/datatypes.py | 34 +-- examples/simple-connector/connector.py | 34 +-- examples/simple-fleet-connector/connector.py | 34 +-- inorbit_connector/connector.py | 12 +- inorbit_connector/models.py | 162 +++++++++--- pyproject.toml | 1 + tests/conftest.py | 17 ++ tests/test_connector.py | 71 +++--- tests/test_connector_metrics.py | 16 +- tests/test_models.py | 252 +++++++++++++------ 11 files changed, 406 insertions(+), 261 deletions(-) create mode 100644 tests/conftest.py diff --git a/examples/fleet-connector/datatypes.py b/examples/fleet-connector/datatypes.py index c1cbf21..0686137 100644 --- a/examples/fleet-connector/datatypes.py +++ b/examples/fleet-connector/datatypes.py @@ -2,15 +2,13 @@ # # SPDX-License-Identifier: MIT -from pydantic import BaseModel, field_validator - -from inorbit_connector.models import ConnectorConfig +from inorbit_connector.models import ConnectorRootConfig, ConnectorSpecificConfig 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. @@ -21,40 +19,20 @@ class ExampleBotConfig(BaseModel): 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): +class ExampleBotConnectorConfig(ConnectorRootConfig): """The configuration for the example bot fleet connector. - Each connector should create a class that inherits from ConnectorConfig. + Each connector should create a class that inherits from ConnectorRootConfig. 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/robot-connector/datatypes.py b/examples/robot-connector/datatypes.py index cc9d268..34dd5e3 100644 --- a/examples/robot-connector/datatypes.py +++ b/examples/robot-connector/datatypes.py @@ -2,15 +2,13 @@ # # SPDX-License-Identifier: MIT -from pydantic import BaseModel, field_validator - -from inorbit_connector.models import ConnectorConfig +from inorbit_connector.models import ConnectorRootConfig, ConnectorSpecificConfig 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 +19,20 @@ 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): +class ExampleBotConnectorConfig(ConnectorRootConfig): """The configuration for the example bot connector. - Each connector should create a class that inherits from ConnectorConfig. + Each connector should create a class that inherits from ConnectorRootConfig. 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/simple-connector/connector.py b/examples/simple-connector/connector.py index 42f6f5e..80fb959 100644 --- a/examples/simple-connector/connector.py +++ b/examples/simple-connector/connector.py @@ -18,12 +18,12 @@ from typing_extensions import override # Third-party -from pydantic import field_validator, BaseModel +from pydantic import field_validator # 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 @@ -31,7 +31,7 @@ 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,15 +42,17 @@ 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): +class ExampleBotConnectorConfig(ConnectorRootConfig): """The configuration for the example bot connector. - Each connector should create a class that inherits from ConnectorConfig. + Each connector should create a class that inherits from ConnectorRootConfig. Attributes: connector_config (ExampleBotConfig): The config with custom fields for the robot @@ -58,28 +60,6 @@ class ExampleBotConnectorConfig(ConnectorConfig): 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.""" diff --git a/examples/simple-fleet-connector/connector.py b/examples/simple-fleet-connector/connector.py index fa2f328..342d7a1 100644 --- a/examples/simple-fleet-connector/connector.py +++ b/examples/simple-fleet-connector/connector.py @@ -18,12 +18,12 @@ from typing_extensions import override # Third-party -from pydantic import field_validator, BaseModel +from pydantic import field_validator # 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 = ( @@ -32,7 +32,7 @@ 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. @@ -43,15 +43,17 @@ class ExampleBotConfig(BaseModel): 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): +class ExampleBotConnectorConfig(ConnectorRootConfig): """The configuration for the example bot connector. - Each connector should create a class that inherits from ConnectorConfig. + Each connector should create a class that inherits from ConnectorRootConfig. Attributes: connector_config (ExampleBotConfig): The config with custom fields for the fleet @@ -59,28 +61,6 @@ class ExampleBotConnectorConfig(ConnectorConfig): 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.""" 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..5ed9b4b 100644 --- a/inorbit_connector/models.py +++ b/inorbit_connector/models.py @@ -6,10 +6,9 @@ # SPDX-License-Identifier: MIT # Standard -import os import re from pathlib import Path -from typing import List, Optional +from typing import ClassVar, List, Optional # Third-party import pytz @@ -17,12 +16,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 +202,93 @@ class RobotConfig(BaseModel): cameras: List[CameraConfig] = [] -class ConnectorConfig(BaseModel): - """Class representing an Inorbit connector model. +class ConnectorSpecificConfig(BaseSettings): + """Base for per-connector vendor config. + + Subclasses set the CONNECTOR_TYPE class variable to get automatic + env-var loading with prefix ``INORBIT_{CONNECTOR_TYPE}_``. + + Example:: + + class ExampleBotConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "example_bot" + example_bot_api_version: str + example_bot_hw_rev: str + + Attributes: + 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="config/.env", + extra="ignore", + ) - This should not be instantiated on its own. + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + """Override env sources to use ``INORBIT_{CONNECTOR_TYPE}_`` as prefix. + + The ``dotenv_settings.env_file`` value is forwarded so that callers + can still override the file via ``_env_file`` at instantiation time. + + Precedence (highest first): init kwargs > env vars > .env file > + field defaults. + """ + prefix = f"INORBIT_{cls.CONNECTOR_TYPE.upper()}_" + env_settings = EnvSettingsSource( + settings_cls, + env_prefix=prefix, + env_ignore_empty=True, + case_sensitive=False, + ) + dotenv_settings = DotEnvSettingsSource( + settings_cls, + env_file=dotenv_settings.env_file, + env_prefix=prefix, + env_ignore_empty=True, + case_sensitive=False, + ) + return ( + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ) - A Connector specific configuration should be defined in a subclass adding the - "connector_config" field. - The following environment variables will be read during instantiation: +class ConnectorRootConfig(BaseSettings): + """Top-level InOrbit connector configuration. - * 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 + Reads ``INORBIT_*`` environment variables and ``config/.env`` at + **instantiation time** via pydantic-settings. Init kwargs (typically + loaded from YAML) take precedence over env vars. - in addition to those read by the Edge SDK during connector initialization. + Subclass this and narrow the ``connector_config`` field to a concrete + ``ConnectorSpecificConfig`` subclass. Attributes: api_key (str | None, optional): The InOrbit API key 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 @@ -241,20 +311,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="config/.env", + 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: ConnectorSpecificConfig use_websockets: bool = False update_freq: float = 1.0 location_tz: str = DEFAULT_TIMEZONE @@ -267,7 +340,35 @@ class ConnectorConfig(BaseModel): metrics: MetricsConfig = MetricsConfig() fleet: list[RobotConfig] - def to_singular_config(self, robot_id: str) -> "ConnectorConfig": + @model_validator(mode="before") + @classmethod + def _instantiate_connector_config(cls, data): + """Explicitly construct the ``connector_config`` via ``__init__`` when + it arrives as a raw dict (e.g. from YAML). + + Pydantic's default dict-to-model coercion uses ``model_validate``, + which does **not** trigger BaseSettings env-var resolution. Only + ``__init__`` does. This validator detects a dict value and calls + the annotated ConnectorSpecificConfig subclass constructor so that + env sources participate in field resolution. + + The abstract ``ConnectorSpecificConfig`` base is skipped (it has no + ``CONNECTOR_TYPE``); only concrete subclasses are instantiated. + """ + 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, ConnectorSpecificConfig) + and ann_type is not ConnectorSpecificConfig + ): + data = { + **data, + "connector_config": ann_type(**data["connector_config"]), + } + return data + + def to_singular_config(self, robot_id: str) -> "ConnectorRootConfig": """Filters out configurations not related to the given robot. The result is a config with a fleet field of length 1. @@ -275,9 +376,9 @@ 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) + ConnectorRootConfig: The filtered configuration + (preserves the subclass type) """ - # 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 +387,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..ecf834c 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\"", 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_connector.py b/tests/test_connector.py index 74e2b97..0a67b25 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,7 +394,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")], ) @@ -575,7 +576,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 +686,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 +708,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 +738,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 +801,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 +832,13 @@ 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 +846,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 +885,7 @@ 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 +907,7 @@ 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 +936,7 @@ 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() @@ -1012,7 +1013,7 @@ 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 +1031,7 @@ 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 +1041,7 @@ 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 +1100,7 @@ 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 +1110,7 @@ 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 +1128,7 @@ 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 +1147,7 @@ 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() @@ -1167,7 +1168,7 @@ def test_handle_command_exception_with_command_failure( """Test that CommandFailure exceptions are properly 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() @@ -1196,7 +1197,7 @@ def test_handle_command_exception_with_generic_exception( """Test that generic exceptions are handled and passed to result_function with generic message.""" connector = Connector( "TestRobot", - ConnectorConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), ) result_function = MagicMock() @@ -1222,7 +1223,7 @@ 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..d189b4c 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 _MinimalConnectorRootConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "minimal" class _MinimalConnector(FleetConnector): @@ -40,7 +40,7 @@ def _make_config(tmp_path, **overrides): base = dict( api_key="ak", connector_type="test", - connector_config=_MinimalConnectorConfig(), + connector_config=_MinimalConnectorRootConfig(), fleet=[RobotConfig(robot_id="r1")], metrics=MetricsConfig( enabled=True, @@ -52,7 +52,7 @@ def _make_config(tmp_path, **overrides): ), ) base.update(overrides) - return ConnectorConfig(**base) + return ConnectorRootConfig(**base) @pytest.fixture(autouse=True) @@ -98,10 +98,10 @@ 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(), + connector_config=_MinimalConnectorRootConfig(), fleet=[RobotConfig(robot_id="r1")], ) conn = _MinimalConnector(cfg) diff --git a/tests/test_models.py b/tests/test_models.py index ea145db..d90590f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,21 +6,19 @@ # 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, @@ -32,8 +30,8 @@ from inorbit_connector.logging.logger import LogLevels -class DummyConfig(BaseModel): - pass +class DummyConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "dummy" class InvalidDummyConfig(IndexError): @@ -56,7 +54,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 +69,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 +84,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 +93,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 +103,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 +116,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 +124,142 @@ 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( + 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( + 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_when_no_env(self): + model = ConnectorRootConfig( + connector_type="valid_connector", + connector_config=DummyConfig(), + fleet=[{"robot_id": "robot1"}], + _env_file=None, + ) + assert model.api_key is None - 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_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" + + 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_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_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"}], - } - model = ReloadedConfig(**init_input) - assert str(model.api_url) == INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL + class FieldedConfig(ConnectorSpecificConfig): + CONNECTOR_TYPE = "test_bot" + some_field: str = "default" - @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 + class RootWithFielded(ConnectorRootConfig): + connector_config: FieldedConfig - init_input = { - "connector_type": "valid_connector", - "connector_config": DummyConfig(), - "fleet": [{"robot_id": "robot1"}], - } - model = ReloadedConfig(**init_input) - assert model.api_key is None + 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 TestMapConfigBase: @@ -390,8 +436,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 @@ -415,12 +459,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 From 32453cdd106fc06f77ae258aad4de192adfb4e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= Date: Mon, 25 May 2026 23:05:59 -0300 Subject: [PATCH 02/10] Update docs --- docs/contents/configuration.md | 37 ++++++++++++--------------- docs/contents/specification/index.md | 3 ++- docs/contents/specification/models.md | 33 +++++++++++++++++++----- docs/contents/usage/fleet.md | 4 +-- docs/contents/usage/metrics.md | 2 +- docs/contents/usage/single-robot.md | 4 +-- 6 files changed, 51 insertions(+), 32 deletions(-) diff --git a/docs/contents/configuration.md b/docs/contents/configuration.md index ed1f053..11389de 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 should subclass `inorbit_connector.models.ConnectorRootConfig` and narrow the `connector_config` field to a concrete `ConnectorSpecificConfig` subclass. 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_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) @@ -30,7 +30,7 @@ Connectors should subclass `inorbit_connector.models.ConnectorConfig` and define ### 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_URL`** (optional): The InOrbit API endpoint URL @@ -78,29 +78,26 @@ 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`: +To create a connector-specific configuration, subclass `ConnectorSpecificConfig` for the vendor-specific fields and `ConnectorRootConfig` for the top-level config: ```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 +class MyRootConfig(ConnectorRootConfig): + """Top-level configuration for your connector.""" + connector_config: MyConnectorConfig ``` +`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,6 @@ 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 = MyRootConfig(**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..c5b411d 100644 --- a/docs/contents/specification/models.md +++ b/docs/contents/specification/models.md @@ -5,21 +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. 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. +- You typically **subclass** this and narrow the `connector_config` field to a concrete `ConnectorSpecificConfig` subclass. +- 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. -### `to_singular_config(robot_id) -> ConnectorConfig` +### `to_singular_config(robot_id) -> ConnectorRootConfig` Returns a config instance of the same subclass 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` From 9667cf4bca4e9b421ab88838a8f36a23302d7abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= Date: Mon, 25 May 2026 23:37:23 -0300 Subject: [PATCH 03/10] Make ConnectorRootConfig generic over ConnectorSpecificConfig ConnectorRootConfig is now Generic[T] where T is bound to ConnectorSpecificConfig. Connectors parametrize it directly (ConnectorRootConfig[MyConfig](**yaml_data)) instead of writing a boilerplate subclass. Type checkers resolve connector_config to the concrete type. Existing subclasses continue to work. Also uses Self return type for to_singular_config to preserve both generic parameters and subclass types for type checkers. Co-Authored-By: Claude Opus 4.6 --- docs/contents/configuration.md | 11 +-- docs/contents/specification/models.md | 8 +- examples/fleet-connector/connector.py | 7 +- examples/fleet-connector/datatypes.py | 14 +-- examples/fleet-connector/main.py | 5 +- examples/robot-connector/connector.py | 7 +- examples/robot-connector/datatypes.py | 14 +-- examples/robot-connector/main.py | 5 +- examples/simple-connector/connector.py | 18 +--- examples/simple-fleet-connector/connector.py | 16 +--- inorbit_connector/models.py | 22 +++-- tests/test_models.py | 89 ++++++++++++++++++-- 12 files changed, 126 insertions(+), 90 deletions(-) diff --git a/docs/contents/configuration.md b/docs/contents/configuration.md index 11389de..b53933e 100644 --- a/docs/contents/configuration.md +++ b/docs/contents/configuration.md @@ -9,7 +9,7 @@ The `inorbit-connector` framework uses Pydantic models for configuration, provid 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.ConnectorRootConfig` and narrow the `connector_config` field to a concrete `ConnectorSpecificConfig` subclass. 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 @@ -78,7 +78,7 @@ 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 `ConnectorSpecificConfig` for the vendor-specific fields and `ConnectorRootConfig` for the top-level config: +Subclass `ConnectorSpecificConfig` for your vendor-specific fields, then parametrize `ConnectorRootConfig` with it directly: ```python from inorbit_connector.models import ConnectorRootConfig, ConnectorSpecificConfig @@ -91,9 +91,7 @@ class MyConnectorConfig(ConnectorSpecificConfig): hardware_revision: str custom_setting: str -class MyRootConfig(ConnectorRootConfig): - """Top-level configuration for your connector.""" - connector_config: MyConnectorConfig +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. @@ -110,6 +108,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 = MyRootConfig(**yaml_data) +config = ConnectorRootConfig[MyConnectorConfig](**yaml_data) ``` - diff --git a/docs/contents/specification/models.md b/docs/contents/specification/models.md index c5b411d..8d855c8 100644 --- a/docs/contents/specification/models.md +++ b/docs/contents/specification/models.md @@ -8,17 +8,17 @@ This page specifies the configuration models defined by `inorbit_connector.model (spec-models-connectorrootconfig)= ## `ConnectorRootConfig` -Top-level configuration model for connectors. Subclasses `BaseSettings` from pydantic-settings. +Top-level configuration model for connectors. Subclasses `BaseSettings` from pydantic-settings and is generic over `T: ConnectorSpecificConfig`. Key points: -- You typically **subclass** this and narrow the `connector_config` field to a concrete `ConnectorSpecificConfig` subclass. +- **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. -### `to_singular_config(robot_id) -> ConnectorRootConfig` +### `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` diff --git a/examples/fleet-connector/connector.py b/examples/fleet-connector/connector.py index b3c8258..081d7f9 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,10 @@ 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 0686137..a52d582 100644 --- a/examples/fleet-connector/datatypes.py +++ b/examples/fleet-connector/datatypes.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -from inorbit_connector.models import ConnectorRootConfig, ConnectorSpecificConfig +from inorbit_connector.models import ConnectorSpecificConfig CONNECTOR_TYPE = "example_bot" @@ -24,15 +24,3 @@ class ExampleBotConfig(ConnectorSpecificConfig): example_bot_api_version: str example_bot_hw_rev: str example_bot_custom_value: str - - -class ExampleBotConnectorConfig(ConnectorRootConfig): - """The configuration for the example bot fleet connector. - - Each connector should create a class that inherits from ConnectorRootConfig. - - Attributes: - connector_config (ExampleBotConfig): The config with custom fields for the fleet - """ - - connector_config: ExampleBotConfig diff --git a/examples/fleet-connector/main.py b/examples/fleet-connector/main.py index dcc1800..3b0c284 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. @@ -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..7a4e261 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,10 @@ 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 34dd5e3..5381c84 100644 --- a/examples/robot-connector/datatypes.py +++ b/examples/robot-connector/datatypes.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -from inorbit_connector.models import ConnectorRootConfig, ConnectorSpecificConfig +from inorbit_connector.models import ConnectorSpecificConfig CONNECTOR_TYPE = "example_bot" @@ -24,15 +24,3 @@ class ExampleBotConfig(ConnectorSpecificConfig): example_bot_api_version: str example_bot_hw_rev: str example_bot_custom_value: str - - -class ExampleBotConnectorConfig(ConnectorRootConfig): - """The configuration for the example bot connector. - - Each connector should create a class that inherits from ConnectorRootConfig. - - Attributes: - connector_config (ExampleBotConfig): The config with custom fields for the robot - """ - - connector_config: ExampleBotConfig 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 80fb959..532eff5 100644 --- a/examples/simple-connector/connector.py +++ b/examples/simple-connector/connector.py @@ -49,18 +49,6 @@ class ExampleBotConfig(ConnectorSpecificConfig): example_bot_custom_value: str -class ExampleBotConnectorConfig(ConnectorRootConfig): - """The configuration for the example bot connector. - - Each connector should create a class that inherits from ConnectorRootConfig. - - Attributes: - connector_config (ExampleBotConfig): The config with custom fields for the robot - """ - - connector_config: ExampleBotConfig - - 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)) @@ -81,10 +69,10 @@ 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 @@ -181,7 +169,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 342d7a1..1dba670 100644 --- a/examples/simple-fleet-connector/connector.py +++ b/examples/simple-fleet-connector/connector.py @@ -50,16 +50,6 @@ class ExampleBotConfig(ConnectorSpecificConfig): example_bot_custom_value: str -class ExampleBotConnectorConfig(ConnectorRootConfig): - """The configuration for the example bot connector. - - Each connector should create a class that inherits from ConnectorRootConfig. - - Attributes: - connector_config (ExampleBotConfig): The config with custom fields for the fleet - """ - - connector_config: ExampleBotConfig async def get_fleet_robot_data(robot_id: str) -> dict: @@ -100,10 +90,10 @@ 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 @@ -230,7 +220,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/models.py b/inorbit_connector/models.py index 5ed9b4b..95a938b 100644 --- a/inorbit_connector/models.py +++ b/inorbit_connector/models.py @@ -8,7 +8,12 @@ # Standard import re from pathlib import Path -from typing import ClassVar, 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 @@ -273,16 +278,16 @@ def settings_customise_sources( ) -class ConnectorRootConfig(BaseSettings): +T = TypeVar("T", bound=ConnectorSpecificConfig) + + +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. - Subclass this and narrow the ``connector_config`` field to a concrete - ``ConnectorSpecificConfig`` subclass. - Attributes: api_key (str | None, optional): The InOrbit API key api_url (HttpUrl, optional): The URL of the API or inorbit_edge's @@ -327,7 +332,7 @@ class ConnectorRootConfig(BaseSettings): default=HttpUrl(INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL), ) connector_type: str - connector_config: ConnectorSpecificConfig + connector_config: T use_websockets: bool = False update_freq: float = 1.0 location_tz: str = DEFAULT_TIMEZONE @@ -368,7 +373,7 @@ def _instantiate_connector_config(cls, data): } return data - def to_singular_config(self, robot_id: str) -> "ConnectorRootConfig": + 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. @@ -376,8 +381,7 @@ def to_singular_config(self, robot_id: str) -> "ConnectorRootConfig": robot_id (str): The ID of the robot to filter the configuration for Returns: - ConnectorRootConfig: The filtered configuration - (preserves the subclass type) + Self: The filtered configuration """ filtered_fleet = [robot for robot in self.fleet if robot.robot_id == robot_id] diff --git a/tests/test_models.py b/tests/test_models.py index d90590f..978e83e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,7 +7,6 @@ # Standard import os -import re # Third-party import pytest @@ -24,10 +23,8 @@ MapConfigTemp, MetricsConfig, RobotConfig, - LoggingConfig, ) from pathlib import Path -from inorbit_connector.logging.logger import LogLevels class DummyConfig(ConnectorSpecificConfig): @@ -262,6 +259,88 @@ class RootWithFielded(ConnectorRootConfig): 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, + } + + 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) + + class TestMapConfigBase: """Tests for the MapConfigBase model.""" @@ -448,9 +527,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): From b9ef792542623c11375e7856aff33d405a213b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= Date: Mon, 25 May 2026 23:37:34 -0300 Subject: [PATCH 04/10] Add tests/ to tox flake8 and black targets and fix violations tox was only linting inorbit_connector/, missing test files entirely. Add tests/ to both flake8 and black commands, then fix all pre-existing violations: unused imports, long docstrings, blank line issues, and formatting inconsistencies. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 4 +-- tests/test_commands.py | 5 +-- tests/test_connector.py | 62 ++++++++++++++++++++++++--------- tests/test_connector_metrics.py | 3 +- tests/test_metrics_server.py | 4 +-- tests/test_utils.py | 1 - 6 files changed, 52 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ecf834c..8d1d332 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,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"], + ["black", "--check", "--diff", "inorbit_connector", "tests"], ["coverage", "run", "-m", "pytest"], ["coverage", "html", "-d", "{envlogdir}/coverage"], ] 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 0a67b25..8b15c19 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -832,7 +832,9 @@ def make_connector_not_abstract(self): def base_connector(self, base_model): return Connector( "TestRobot", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) def test_init(self, base_model): @@ -885,7 +887,9 @@ def test_publish_map(self, base_model, mock_robot_session_pool): } connector = Connector( "TestRobot", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) connector.publish_map("frameA") @@ -907,7 +911,9 @@ def test_publish_pose(self, base_model, mock_robot_session_pool): } connector = Connector( "TestRobot", - ConnectorRootConfig(**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") @@ -936,7 +942,9 @@ def test_publish_pose_updates_maps(self, base_model, mock_robot_session_pool): } connector = Connector( "TestRobot", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) session = connector._get_session() @@ -994,7 +1002,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( @@ -1013,7 +1021,9 @@ async def test_register_user_scripts( # Create a connector with user scripts enabled connector = Connector( "TestRobot", - ConnectorRootConfig(**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, ) @@ -1031,7 +1041,9 @@ def test_uses_env_vars(self, base_model): base_model["env_vars"] = {"ENV_VAR": "env_value"} Connector( "TestRobot", - ConnectorRootConfig(**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" @@ -1041,7 +1053,9 @@ async def test_start_stop_integration(self, base_model): """Integration test for start/stop functionality.""" connector = Connector( "TestRobot", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) connector._execution_loop = AsyncMock() connector._connect = AsyncMock() @@ -1100,7 +1114,9 @@ def make_connector_not_abstract(self): def base_connector(self, base_model): return Connector( "TestRobot", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) @pytest.mark.asyncio @@ -1110,7 +1126,9 @@ async def test_register_command_handler_by_default( """Test that command handler is registered by default.""" connector = Connector( "TestRobot", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) connector._connect = AsyncMock() @@ -1128,7 +1146,9 @@ async def test_does_not_register_when_disabled( """Test that command handler is not registered when disabled.""" connector = Connector( "TestRobot", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), register_custom_command_handler=False, ) connector._connect = AsyncMock() @@ -1147,7 +1167,9 @@ async def test_sets_online_status_callback( """Test that online status callback is set on EdgeSDK.""" connector = Connector( "TestRobot", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) connector._connect = AsyncMock() @@ -1165,10 +1187,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", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) result_function = MagicMock() @@ -1194,10 +1218,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", - ConnectorRootConfig(**base_model, fleet=[RobotConfig(robot_id="TestRobot")]), + ConnectorRootConfig( + **base_model, fleet=[RobotConfig(robot_id="TestRobot")] + ), ) result_function = MagicMock() @@ -1223,7 +1249,9 @@ def test_handle_command_exception_without_message( """Test that exceptions without a message use the class name as stderr.""" connector = Connector( "TestRobot", - ConnectorRootConfig(**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 d189b4c..a35f3f9 100644 --- a/tests/test_connector_metrics.py +++ b/tests/test_connector_metrics.py @@ -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, ) 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_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") From 4ba61aaeeb21d8629c43ffbe5eac665d2467d405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= Date: Tue, 26 May 2026 07:11:54 -0300 Subject: [PATCH 05/10] Address comments --- examples/fleet-connector/connector.py | 3 ++- examples/fleet-connector/datatypes.py | 5 +---- examples/fleet-connector/main.py | 2 +- examples/robot-connector/connector.py | 9 ++++++-- examples/robot-connector/datatypes.py | 3 --- examples/simple-connector/connector.py | 13 ++++++------ examples/simple-fleet-connector/connector.py | 22 ++++++++------------ pyproject.toml | 4 ++-- 8 files changed, 29 insertions(+), 32 deletions(-) diff --git a/examples/fleet-connector/connector.py b/examples/fleet-connector/connector.py index 081d7f9..96637c8 100644 --- a/examples/fleet-connector/connector.py +++ b/examples/fleet-connector/connector.py @@ -39,7 +39,8 @@ class ExampleBotFleetConnector(FleetConnector): Args: robot_ids (list[str]): List of robot IDs in the fleet - config (ConnectorRootConfig[ExampleBotConfig]): The configuration for the connector + config (ConnectorRootConfig[ExampleBotConfig]): + The configuration for the connector """ def __init__(self, config: ConnectorRootConfig[ExampleBotConfig]) -> None: diff --git a/examples/fleet-connector/datatypes.py b/examples/fleet-connector/datatypes.py index a52d582..a22d947 100644 --- a/examples/fleet-connector/datatypes.py +++ b/examples/fleet-connector/datatypes.py @@ -5,16 +5,13 @@ from inorbit_connector.models import ConnectorSpecificConfig -CONNECTOR_TYPE = "example_bot" - - 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 """ diff --git a/examples/fleet-connector/main.py b/examples/fleet-connector/main.py index 3b0c284..1e63511 100644 --- a/examples/fleet-connector/main.py +++ b/examples/fleet-connector/main.py @@ -30,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", diff --git a/examples/robot-connector/connector.py b/examples/robot-connector/connector.py index 7a4e261..1e32c74 100644 --- a/examples/robot-connector/connector.py +++ b/examples/robot-connector/connector.py @@ -37,10 +37,15 @@ class ExampleBotConnector(Connector): Args: robot_id (str): The ID of the InOrbit robot - config (ConnectorRootConfig[ExampleBotConfig]): The configuration for the connector + config (ConnectorRootConfig[ExampleBotConfig]): + The configuration for the connector """ - def __init__(self, robot_id: str, config: ConnectorRootConfig[ExampleBotConfig]) -> 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 5381c84..f633291 100644 --- a/examples/robot-connector/datatypes.py +++ b/examples/robot-connector/datatypes.py @@ -5,9 +5,6 @@ from inorbit_connector.models import ConnectorSpecificConfig -CONNECTOR_TYPE = "example_bot" - - class ExampleBotConfig(ConnectorSpecificConfig): """The configuration for the example bot. diff --git a/examples/simple-connector/connector.py b/examples/simple-connector/connector.py index 532eff5..9aa9fa0 100644 --- a/examples/simple-connector/connector.py +++ b/examples/simple-connector/connector.py @@ -17,9 +17,6 @@ except ImportError: from typing_extensions import override -# Third-party -from pydantic import field_validator - # InOrbit from inorbit_connector.commands import CommandResultCode from inorbit_connector.connector import Connector @@ -28,7 +25,6 @@ CONFIG_FILE = Path(__file__).resolve().parent.parent / "example.yaml" # ../example.yaml ROBOT_ID = "my-example-robot" -CONNECTOR_TYPE = "example_bot" class ExampleBotConfig(ConnectorSpecificConfig): @@ -69,10 +65,15 @@ class ExampleBotConnector(Connector): Args: robot_id (str): The ID of the InOrbit robot - config (ConnectorRootConfig[ExampleBotConfig]): The configuration for the connector + config (ConnectorRootConfig[ExampleBotConfig]): + The configuration for the connector """ - def __init__(self, robot_id: str, config: ConnectorRootConfig[ExampleBotConfig]) -> 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/simple-fleet-connector/connector.py b/examples/simple-fleet-connector/connector.py index 1dba670..524a504 100644 --- a/examples/simple-fleet-connector/connector.py +++ b/examples/simple-fleet-connector/connector.py @@ -17,9 +17,6 @@ except ImportError: from typing_extensions import override -# Third-party -from pydantic import field_validator - # InOrbit from inorbit_connector.commands import CommandResultCode from inorbit_connector.connector import FleetConnector @@ -29,7 +26,6 @@ CONFIG_FILE = ( Path(__file__).resolve().parent.parent / "example.fleet.yaml" ) # ../example.fleet.yaml -CONNECTOR_TYPE = "example_bot" class ExampleBotConfig(ConnectorSpecificConfig): @@ -38,7 +34,7 @@ class ExampleBotConfig(ConnectorSpecificConfig): 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 """ @@ -50,8 +46,6 @@ class ExampleBotConfig(ConnectorSpecificConfig): example_bot_custom_value: str - - 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)) @@ -85,12 +79,14 @@ 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 (ConnectorRootConfig[ExampleBotConfig]): The configuration for the connector + config (ConnectorRootConfig[ExampleBotConfig]): + The configuration for the connector """ def __init__(self, config: ConnectorRootConfig[ExampleBotConfig]) -> None: @@ -129,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 diff --git a/pyproject.toml b/pyproject.toml index 8d1d332..e91cae7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,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", "tests"], - ["black", "--check", "--diff", "inorbit_connector", "tests"], + ["flake8", "inorbit_connector", "tests", "examples"], + ["black", "--check", "--diff", "inorbit_connector", "tests", "examples"], ["coverage", "run", "-m", "pytest"], ["coverage", "html", "-d", "{envlogdir}/coverage"], ] From b52b4f4c2e034a3b47edf74ffd4535b40ad0ddc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= Date: Tue, 26 May 2026 07:24:39 -0300 Subject: [PATCH 06/10] Allow for customizing dotenv path --- docs/contents/configuration.md | 2 + docs/contents/specification/models.md | 1 + inorbit_connector/models.py | 84 +++++++++++++++------------ tests/test_models.py | 59 +++++++++++++++++++ 4 files changed, 108 insertions(+), 38 deletions(-) diff --git a/docs/contents/configuration.md b/docs/contents/configuration.md index b53933e..662d175 100644 --- a/docs/contents/configuration.md +++ b/docs/contents/configuration.md @@ -35,6 +35,8 @@ Connectors parametrize `ConnectorRootConfig[T]` with a concrete `ConnectorSpecif - **`INORBIT_API_KEY`** (required): The InOrbit API key - **`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: diff --git a/docs/contents/specification/models.md b/docs/contents/specification/models.md index 8d855c8..b2bf2e7 100644 --- a/docs/contents/specification/models.md +++ b/docs/contents/specification/models.md @@ -15,6 +15,7 @@ Key points: - **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) -> Self` diff --git a/inorbit_connector/models.py b/inorbit_connector/models.py index 95a938b..c92ebf4 100644 --- a/inorbit_connector/models.py +++ b/inorbit_connector/models.py @@ -7,6 +7,7 @@ # Standard import re +from contextvars import ContextVar from pathlib import Path from typing import ClassVar, Generic, List, Optional, TypeVar @@ -207,6 +208,9 @@ class RobotConfig(BaseModel): cameras: List[CameraConfig] = [] +DEFAULT_ENV_FILE = "config/.env" + + class ConnectorSpecificConfig(BaseSettings): """Base for per-connector vendor config. @@ -235,7 +239,7 @@ class ExampleBotConfig(ConnectorSpecificConfig): model_config = SettingsConfigDict( env_ignore_empty=True, case_sensitive=False, - env_file="config/.env", + env_file=DEFAULT_ENV_FILE, extra="ignore", ) @@ -248,38 +252,26 @@ def settings_customise_sources( dotenv_settings, file_secret_settings, ): - """Override env sources to use ``INORBIT_{CONNECTOR_TYPE}_`` as prefix. - - The ``dotenv_settings.env_file`` value is forwarded so that callers - can still override the file via ``_env_file`` at instantiation time. - - Precedence (highest first): init kwargs > env vars > .env file > - field defaults. - """ + """Use ``INORBIT_{CONNECTOR_TYPE}_`` as env-var prefix.""" prefix = f"INORBIT_{cls.CONNECTOR_TYPE.upper()}_" - env_settings = EnvSettingsSource( - settings_cls, - env_prefix=prefix, - env_ignore_empty=True, - case_sensitive=False, - ) - dotenv_settings = DotEnvSettingsSource( - settings_cls, - env_file=dotenv_settings.env_file, - env_prefix=prefix, - env_ignore_empty=True, - case_sensitive=False, - ) + common = dict(env_prefix=prefix, env_ignore_empty=True, case_sensitive=False) return ( init_settings, - env_settings, - dotenv_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. @@ -288,6 +280,16 @@ class ConnectorRootConfig(BaseSettings, Generic[T]): **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. + Attributes: api_key (str | None, optional): The InOrbit API key api_url (HttpUrl, optional): The URL of the API or inorbit_edge's @@ -323,7 +325,7 @@ class ConnectorRootConfig(BaseSettings, Generic[T]): env_prefix="INORBIT_", env_ignore_empty=True, case_sensitive=False, - env_file="config/.env", + env_file=DEFAULT_ENV_FILE, extra="ignore", ) @@ -345,21 +347,21 @@ class ConnectorRootConfig(BaseSettings, Generic[T]): metrics: MetricsConfig = MetricsConfig() fleet: list[RobotConfig] + 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): - """Explicitly construct the ``connector_config`` via ``__init__`` when - it arrives as a raw dict (e.g. from YAML). - - Pydantic's default dict-to-model coercion uses ``model_validate``, - which does **not** trigger BaseSettings env-var resolution. Only - ``__init__`` does. This validator detects a dict value and calls - the annotated ConnectorSpecificConfig subclass constructor so that - env sources participate in field resolution. - - The abstract ``ConnectorSpecificConfig`` base is skipped (it has no - ``CONNECTOR_TYPE``); only concrete subclasses are instantiated. - """ + """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 ( @@ -367,9 +369,15 @@ def _instantiate_connector_config(cls, data): and issubclass(ann_type, ConnectorSpecificConfig) and ann_type is not 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"]), + "connector_config": ann_type( + **data["connector_config"], **env_file_kwargs + ), } return data diff --git a/tests/test_models.py b/tests/test_models.py index 978e83e..60bffe5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -340,6 +340,65 @@ def test_to_singular_config_preserves_generic_type(self, base_kwargs): 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]( + 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]( + 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( + 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: """Tests for the MapConfigBase model.""" From 7bb28da56fdb9ce0f66e398d0cd9d2071c5aa8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= Date: Tue, 26 May 2026 08:21:59 -0300 Subject: [PATCH 07/10] Update test_connector.py --- tests/test_connector.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_connector.py b/tests/test_connector.py index 8b15c19..060d554 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -400,13 +400,10 @@ def fleet_connector(self, base_model): ) ) - 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( From 75cfcd01483ece806269b88b78b0781d9bc73281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= <41840767+b-Tomas@users.noreply.github.com> Date: Tue, 26 May 2026 08:23:43 -0300 Subject: [PATCH 08/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- inorbit_connector/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inorbit_connector/models.py b/inorbit_connector/models.py index c92ebf4..57d4d44 100644 --- a/inorbit_connector/models.py +++ b/inorbit_connector/models.py @@ -366,8 +366,8 @@ def _instantiate_connector_config(cls, data): ann_type = cls.model_fields["connector_config"].annotation if ( isinstance(ann_type, type) - and issubclass(ann_type, ConnectorSpecificConfig) - and ann_type is not ConnectorSpecificConfig + and issubclass(ann_type, BaseSettings) + and ann_type not in (BaseSettings, ConnectorSpecificConfig) ): env_file_kwargs = {} env_file = _env_file_var.get() From b6b718767f036b14f0d373620b0204b19c5e12f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= Date: Tue, 26 May 2026 08:25:47 -0300 Subject: [PATCH 09/10] Update test_connector_metrics.py --- tests/test_connector_metrics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_connector_metrics.py b/tests/test_connector_metrics.py index a35f3f9..e911836 100644 --- a/tests/test_connector_metrics.py +++ b/tests/test_connector_metrics.py @@ -16,7 +16,7 @@ ) -class _MinimalConnectorRootConfig(ConnectorSpecificConfig): +class _MinimalConnectorConfig(ConnectorSpecificConfig): CONNECTOR_TYPE = "minimal" @@ -40,7 +40,7 @@ def _make_config(tmp_path, **overrides): base = dict( api_key="ak", connector_type="test", - connector_config=_MinimalConnectorRootConfig(), + connector_config=_MinimalConnectorConfig(), fleet=[RobotConfig(robot_id="r1")], metrics=MetricsConfig( enabled=True, @@ -100,7 +100,7 @@ def test_metrics_disabled_by_default(tmp_path, patched_run_connector): cfg = ConnectorRootConfig( api_key="ak", connector_type="test", - connector_config=_MinimalConnectorRootConfig(), + connector_config=_MinimalConnectorConfig(), fleet=[RobotConfig(robot_id="r1")], ) conn = _MinimalConnector(cfg) From 25135fdd8025e0d2306e43d808d5dc7b6a5baad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Badenes?= Date: Tue, 26 May 2026 09:24:23 -0300 Subject: [PATCH 10/10] Validate that at least one of api_key or inorbit_robot_key is set Without this check the connector silently passes None credentials to the edge SDK, which results in a confusing 403 at runtime instead of a clear validation error at config instantiation time. Co-Authored-By: Claude Opus 4.6 --- docs/contents/configuration.md | 6 +++--- inorbit_connector/models.py | 24 +++++++++++++++++++++++- tests/test_models.py | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/contents/configuration.md b/docs/contents/configuration.md index 662d175..8b90b7a 100644 --- a/docs/contents/configuration.md +++ b/docs/contents/configuration.md @@ -13,7 +13,7 @@ Connectors parametrize `ConnectorRootConfig[T]` with a concrete `ConnectorSpecif ### 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`** (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}_` @@ -25,14 +25,14 @@ Connectors parametrize `ConnectorRootConfig[T]` with a concrete `ConnectorSpecif - **`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 `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. diff --git a/inorbit_connector/models.py b/inorbit_connector/models.py index 57d4d44..b92dd8d 100644 --- a/inorbit_connector/models.py +++ b/inorbit_connector/models.py @@ -290,8 +290,13 @@ class ConnectorRootConfig(BaseSettings, Generic[T]): 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 + 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 @@ -310,6 +315,7 @@ class ConnectorRootConfig(BaseSettings, Generic[T]): 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 @@ -381,6 +387,22 @@ def _instantiate_connector_config(cls, data): } 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. diff --git a/tests/test_models.py b/tests/test_models.py index 60bffe5..313b26c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -170,6 +170,7 @@ def test_reads_api_key_from_env(self, monkeypatch): 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"}], @@ -179,6 +180,7 @@ def test_reads_api_url_from_env(self, monkeypatch): 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"}], @@ -186,14 +188,17 @@ def test_api_url_default_when_no_env(self): ) assert str(model.api_url) == INORBIT_CLOUD_SDK_ROBOT_CONFIG_URL - def test_api_key_none_when_no_env(self): + 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") @@ -217,6 +222,30 @@ def test_reads_from_env_file(self, tmp_path): ) assert model.api_key == "from_dotenv" + 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) + + 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" + + 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).""" @@ -350,6 +379,7 @@ class FieldedConfig(ConnectorSpecificConfig): 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"}], @@ -372,6 +402,7 @@ class FieldedConfig(ConnectorSpecificConfig): "INORBIT_TEST_BOT_SOME_FIELD=from_dotenv\n" ) config = ConnectorRootConfig[FieldedConfig]( + api_key="ak", connector_type="test_bot", connector_config={}, fleet=[{"robot_id": "r1"}], @@ -392,6 +423,7 @@ class RootWithFielded(ConnectorRootConfig): 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"}],