From 157ea544138f4c45542c11a681c910305881e178 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 20 Mar 2026 23:57:21 +0000 Subject: [PATCH 01/22] first pass --- src/groundlight/experimental_api.py | 17 +++++++++++++++++ test/unit/test_edge_config.py | 27 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 67bc6648..9251b57a 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -40,6 +40,7 @@ ) from urllib3.response import HTTPResponse +from groundlight.edge.config import EdgeEndpointConfig from groundlight.images import parse_supported_image_types from groundlight.internalapi import _generate_request_id from groundlight.optional_imports import Image, np @@ -817,3 +818,19 @@ def make_generic_api_request( # noqa: PLR0913 # pylint: disable=too-many-argume auth_settings=["ApiToken"], _preload_content=False, # This returns the urllib3 response rather than trying any type of processing ) + + def get_edge_config(self) -> EdgeEndpointConfig: + """Retrieve the active edge endpoint configuration. + + Only works when the client is pointed at an edge endpoint + (via GROUNDLIGHT_ENDPOINT or the endpoint constructor arg). + """ + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(self.configuration.host) + base_url = urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) + url = f"{base_url}/edge-config" + headers = self.get_raw_headers() + response = requests.get(url, headers=headers, verify=self.configuration.verify_ssl) + response.raise_for_status() + return EdgeEndpointConfig.from_payload(response.json()) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 469e6061..a3e3b3db 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -307,3 +307,30 @@ def test_inference_config_validation_errors(): always_return_edge_prediction=True, min_time_between_escalations=-1.0, ) + + +def test_get_edge_config_parses_response(): + """ExperimentalApi.get_edge_config() parses the HTTP response into an EdgeEndpointConfig.""" + from unittest.mock import Mock, patch + + from groundlight import ExperimentalApi + + payload = { + "global_config": {"refresh_rate": REFRESH_RATE_SECONDS}, + "edge_inference_configs": {"default": {"enabled": True}}, + "detectors": [{"detector_id": "det_1", "edge_inference_config": "default"}], + } + + mock_response = Mock() + mock_response.json.return_value = payload + mock_response.raise_for_status = Mock() + + gl = ExperimentalApi() + with patch("requests.get", return_value=mock_response) as mock_get: + config = gl.get_edge_config() + + mock_get.assert_called_once() + assert isinstance(config, EdgeEndpointConfig) + assert config.global_config.refresh_rate == REFRESH_RATE_SECONDS + assert config.edge_inference_configs["default"].name == "default" + assert [d.detector_id for d in config.detectors] == ["det_1"] From b3f35a2d6ee5840e76d59f7896a0a5e9ac915f17 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Tue, 24 Mar 2026 22:17:04 +0000 Subject: [PATCH 02/22] adding detector readiness check --- src/groundlight/experimental_api.py | 67 ++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 9251b57a..64570e23 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -8,6 +8,7 @@ """ import json +import time from io import BufferedReader, BytesIO from pathlib import Path from typing import Any, Dict, List, Optional, Union @@ -819,18 +820,74 @@ def make_generic_api_request( # noqa: PLR0913 # pylint: disable=too-many-argume _preload_content=False, # This returns the urllib3 response rather than trying any type of processing ) + def _edge_base_url(self) -> str: + """Return the scheme+host+port of the configured endpoint, without the /device-api path.""" + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(self.configuration.host) + return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) + def get_edge_config(self) -> EdgeEndpointConfig: """Retrieve the active edge endpoint configuration. Only works when the client is pointed at an edge endpoint (via GROUNDLIGHT_ENDPOINT or the endpoint constructor arg). """ - from urllib.parse import urlparse, urlunparse - - parsed = urlparse(self.configuration.host) - base_url = urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) - url = f"{base_url}/edge-config" + url = f"{self._edge_base_url()}/edge-config" headers = self.get_raw_headers() response = requests.get(url, headers=headers, verify=self.configuration.verify_ssl) response.raise_for_status() return EdgeEndpointConfig.from_payload(response.json()) + + def get_edge_detector_readiness(self) -> dict[str, bool]: + """Check which configured detectors have inference pods ready to serve. + + Only works when the client is pointed at an edge endpoint. + + :return: Dict mapping detector_id to readiness (True/False). + """ + url = f"{self._edge_base_url()}/edge-detector-readiness" + headers = self.get_raw_headers() + response = requests.get(url, headers=headers, verify=self.configuration.verify_ssl) + response.raise_for_status() + return {det_id: info["ready"] for det_id, info in response.json().items()} + + def set_edge_config( + self, + config: EdgeEndpointConfig, + mode: str = "REPLACE", + timeout_sec: float = 300, + poll_interval_sec: float = 1, + ) -> EdgeEndpointConfig: + """Send a new edge endpoint configuration and wait until all detectors are ready. + + Only works when the client is pointed at an edge endpoint. + + :param config: The new configuration to apply. + :param mode: Currently only "REPLACE" is supported. + :param timeout_sec: Max seconds to wait for all detectors to become ready. + :param poll_interval_sec: How often to poll readiness while waiting. + :return: The applied configuration as reported by the edge endpoint. + """ + if mode != "REPLACE": + raise ValueError(f"Unsupported mode: {mode!r}. Currently only 'REPLACE' is supported.") + + url = f"{self._edge_base_url()}/edge-config" + headers = self.get_raw_headers() + response = requests.put( + url, json=config.to_payload(), headers=headers, verify=self.configuration.verify_ssl + ) + response.raise_for_status() + + desired_ids = {d.detector_id for d in config.detectors if d.detector_id} + deadline = time.time() + timeout_sec + while time.time() < deadline: + readiness = self.get_edge_detector_readiness() + if desired_ids and all(readiness.get(did, False) for did in desired_ids): + return self.get_edge_config() + time.sleep(poll_interval_sec) + + raise TimeoutError( + f"Edge detectors were not all ready within {timeout_sec}s. " + "The edge endpoint may still be converging." + ) From 993857f3731fd6ce0774133a9dff47e8211d640e Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Tue, 24 Mar 2026 22:17:40 +0000 Subject: [PATCH 03/22] Automatically reformatting code --- src/groundlight/experimental_api.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 64570e23..60c568c4 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -874,9 +874,7 @@ def set_edge_config( url = f"{self._edge_base_url()}/edge-config" headers = self.get_raw_headers() - response = requests.put( - url, json=config.to_payload(), headers=headers, verify=self.configuration.verify_ssl - ) + response = requests.put(url, json=config.to_payload(), headers=headers, verify=self.configuration.verify_ssl) response.raise_for_status() desired_ids = {d.detector_id for d in config.detectors if d.detector_id} @@ -888,6 +886,5 @@ def set_edge_config( time.sleep(poll_interval_sec) raise TimeoutError( - f"Edge detectors were not all ready within {timeout_sec}s. " - "The edge endpoint may still be converging." + f"Edge detectors were not all ready within {timeout_sec}s. The edge endpoint may still be converging." ) From 237c39ffa7e57501a2474ab80b0f30facbcb1b6e Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 26 Mar 2026 18:32:20 +0000 Subject: [PATCH 04/22] implementing the gl.edge pattern --- src/groundlight/__init__.py | 2 +- src/groundlight/client.py | 6 +++ src/groundlight/edge/__init__.py | 2 + src/groundlight/edge/api.py | 78 +++++++++++++++++++++++++++++ src/groundlight/experimental_api.py | 70 ++++++-------------------- 5 files changed, 102 insertions(+), 56 deletions(-) create mode 100644 src/groundlight/edge/api.py diff --git a/src/groundlight/__init__.py b/src/groundlight/__init__.py index 6a23be7e..805fdd33 100644 --- a/src/groundlight/__init__.py +++ b/src/groundlight/__init__.py @@ -7,7 +7,7 @@ # Imports from our code from .client import Groundlight -from .client import GroundlightClientError, ApiTokenError, NotFoundError +from .client import GroundlightClientError, ApiTokenError, EdgeNotAvailableError, NotFoundError from .experimental_api import ExperimentalApi from .binary_labels import Label from .version import get_version diff --git a/src/groundlight/client.py b/src/groundlight/client.py index c18ac01f..11783497 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -69,6 +69,12 @@ class ApiTokenError(GroundlightClientError): pass +class EdgeNotAvailableError(GroundlightClientError): + """Raised when an edge-only method is called against a non-edge endpoint.""" + + pass + + class Groundlight: # pylint: disable=too-many-instance-attributes,too-many-public-methods """ Client for accessing the Groundlight cloud service. Provides methods to create visual detectors, diff --git a/src/groundlight/edge/__init__.py b/src/groundlight/edge/__init__.py index a3479721..85590995 100644 --- a/src/groundlight/edge/__init__.py +++ b/src/groundlight/edge/__init__.py @@ -1,3 +1,4 @@ +from .api import EdgeAPI from .config import ( DEFAULT, DISABLED, @@ -14,6 +15,7 @@ "DEFAULT", "DISABLED", "EDGE_ANSWERS_WITH_ESCALATION", + "EdgeAPI", "NO_CLOUD", "DetectorsConfig", "DetectorConfig", diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py new file mode 100644 index 00000000..c8794b34 --- /dev/null +++ b/src/groundlight/edge/api.py @@ -0,0 +1,78 @@ +import time + +import requests + +from groundlight.client import EdgeNotAvailableError +from groundlight.edge.config import EdgeEndpointConfig + + +_EDGE_METHOD_UNAVAILABLE_HINT = ( + "Make sure the client is pointed at a running edge endpoint " + "(via GROUNDLIGHT_ENDPOINT env var or the endpoint= constructor arg)." +) + + +class EdgeAPI: + """Namespace for edge-endpoint operations, accessed via ``gl.edge``.""" + + def __init__(self, client) -> None: + self._client = client + + def _base_url(self) -> str: + return self._client._edge_base_url() + + def _request(self, method: str, path: str, **kwargs) -> requests.Response: + url = f"{self._base_url()}{path}" + headers = self._client.get_raw_headers() + try: + response = requests.request( + method, url, headers=headers, verify=self._client.configuration.verify_ssl, **kwargs + ) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 404: + raise EdgeNotAvailableError(f"Edge method not available at {url}. {_EDGE_METHOD_UNAVAILABLE_HINT}") from e + raise + except requests.exceptions.ConnectionError as e: + raise EdgeNotAvailableError(f"Could not connect to {self._base_url()}. {_EDGE_METHOD_UNAVAILABLE_HINT}") from e + return response + + def get_config(self) -> EdgeEndpointConfig: + """Retrieve the active edge endpoint configuration.""" + response = self._request("GET", "/edge-config") + return EdgeEndpointConfig.from_payload(response.json()) + + def get_detector_readiness(self) -> dict[str, bool]: + """Check which configured detectors have inference pods ready to serve. + + :return: Dict mapping detector_id to readiness (True/False). + """ + response = self._request("GET", "/edge-detector-readiness") + return {det_id: info["ready"] for det_id, info in response.json().items()} + + def set_config( + self, + config: EdgeEndpointConfig, + timeout_sec: float = 600, + ) -> EdgeEndpointConfig: + """Replace the edge endpoint configuration and wait until all detectors are ready. + + :param config: The new configuration to apply. + :param timeout_sec: Max seconds to wait for all detectors to become ready. + :return: The applied configuration as reported by the edge endpoint. + """ + self._request("PUT", "/edge-config", json=config.to_payload()) + + poll_interval_seconds = 1 + desired_ids = {d.detector_id for d in config.detectors if d.detector_id} + deadline = time.time() + timeout_sec + while time.time() < deadline: + readiness = self.get_detector_readiness() + if desired_ids and all(readiness.get(did, False) for did in desired_ids): + return self.get_config() + time.sleep(poll_interval_seconds) + + raise TimeoutError( + f"Edge detectors were not all ready within {timeout_sec}s. " + "The edge endpoint may still be converging, or may have encountered an error." + ) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 64570e23..fb02f422 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -41,6 +41,7 @@ ) from urllib3.response import HTTPResponse +from groundlight.edge.api import EdgeAPI from groundlight.edge.config import EdgeEndpointConfig from groundlight.images import parse_supported_image_types from groundlight.internalapi import _generate_request_id @@ -105,6 +106,14 @@ def __init__( self.detector_reset_api = DetectorResetApi(self.api_client) self.edge_api = EdgeApi(self.api_client) + self._edge: EdgeAPI | None = None + + @property + def edge(self) -> "EdgeAPI": + """Access edge-endpoint operations (e.g. ``gl.edge.get_config()``).""" + if self._edge is None: + self._edge = EdgeAPI(self) + return self._edge ITEMS_PER_PAGE = 100 @@ -828,66 +837,17 @@ def _edge_base_url(self) -> str: return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) def get_edge_config(self) -> EdgeEndpointConfig: - """Retrieve the active edge endpoint configuration. - - Only works when the client is pointed at an edge endpoint - (via GROUNDLIGHT_ENDPOINT or the endpoint constructor arg). - """ - url = f"{self._edge_base_url()}/edge-config" - headers = self.get_raw_headers() - response = requests.get(url, headers=headers, verify=self.configuration.verify_ssl) - response.raise_for_status() - return EdgeEndpointConfig.from_payload(response.json()) + """Deprecated: use ``gl.edge.get_config()`` instead.""" + return self.edge.get_config() def get_edge_detector_readiness(self) -> dict[str, bool]: - """Check which configured detectors have inference pods ready to serve. - - Only works when the client is pointed at an edge endpoint. - - :return: Dict mapping detector_id to readiness (True/False). - """ - url = f"{self._edge_base_url()}/edge-detector-readiness" - headers = self.get_raw_headers() - response = requests.get(url, headers=headers, verify=self.configuration.verify_ssl) - response.raise_for_status() - return {det_id: info["ready"] for det_id, info in response.json().items()} + """Deprecated: use ``gl.edge.get_detector_readiness()`` instead.""" + return self.edge.get_detector_readiness() def set_edge_config( self, config: EdgeEndpointConfig, - mode: str = "REPLACE", timeout_sec: float = 300, - poll_interval_sec: float = 1, ) -> EdgeEndpointConfig: - """Send a new edge endpoint configuration and wait until all detectors are ready. - - Only works when the client is pointed at an edge endpoint. - - :param config: The new configuration to apply. - :param mode: Currently only "REPLACE" is supported. - :param timeout_sec: Max seconds to wait for all detectors to become ready. - :param poll_interval_sec: How often to poll readiness while waiting. - :return: The applied configuration as reported by the edge endpoint. - """ - if mode != "REPLACE": - raise ValueError(f"Unsupported mode: {mode!r}. Currently only 'REPLACE' is supported.") - - url = f"{self._edge_base_url()}/edge-config" - headers = self.get_raw_headers() - response = requests.put( - url, json=config.to_payload(), headers=headers, verify=self.configuration.verify_ssl - ) - response.raise_for_status() - - desired_ids = {d.detector_id for d in config.detectors if d.detector_id} - deadline = time.time() + timeout_sec - while time.time() < deadline: - readiness = self.get_edge_detector_readiness() - if desired_ids and all(readiness.get(did, False) for did in desired_ids): - return self.get_edge_config() - time.sleep(poll_interval_sec) - - raise TimeoutError( - f"Edge detectors were not all ready within {timeout_sec}s. " - "The edge endpoint may still be converging." - ) + """Deprecated: use ``gl.edge.set_config()`` instead.""" + return self.edge.set_config(config, timeout_sec=timeout_sec) From dde221c77e4bee1267cdfb1c88a84306f6346607 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Thu, 26 Mar 2026 18:41:50 +0000 Subject: [PATCH 05/22] Automatically reformatting code --- src/groundlight/edge/api.py | 9 ++++++--- src/groundlight/experimental_api.py | 1 - 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index c8794b34..e0b24fe5 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -5,7 +5,6 @@ from groundlight.client import EdgeNotAvailableError from groundlight.edge.config import EdgeEndpointConfig - _EDGE_METHOD_UNAVAILABLE_HINT = ( "Make sure the client is pointed at a running edge endpoint " "(via GROUNDLIGHT_ENDPOINT env var or the endpoint= constructor arg)." @@ -31,10 +30,14 @@ def _request(self, method: str, path: str, **kwargs) -> requests.Response: response.raise_for_status() except requests.exceptions.HTTPError as e: if e.response is not None and e.response.status_code == 404: - raise EdgeNotAvailableError(f"Edge method not available at {url}. {_EDGE_METHOD_UNAVAILABLE_HINT}") from e + raise EdgeNotAvailableError( + f"Edge method not available at {url}. {_EDGE_METHOD_UNAVAILABLE_HINT}" + ) from e raise except requests.exceptions.ConnectionError as e: - raise EdgeNotAvailableError(f"Could not connect to {self._base_url()}. {_EDGE_METHOD_UNAVAILABLE_HINT}") from e + raise EdgeNotAvailableError( + f"Could not connect to {self._base_url()}. {_EDGE_METHOD_UNAVAILABLE_HINT}" + ) from e return response def get_config(self) -> EdgeEndpointConfig: diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index fb02f422..950943b0 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -8,7 +8,6 @@ """ import json -import time from io import BufferedReader, BytesIO from pathlib import Path from typing import Any, Dict, List, Optional, Union From e7c7e9601cad595af11a49571865bbe9745b50a7 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 26 Mar 2026 18:52:27 +0000 Subject: [PATCH 06/22] cleanup --- src/groundlight/experimental_api.py | 4 +--- test/unit/test_edge_config.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index fb02f422..0844b016 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -8,9 +8,9 @@ """ import json -import time from io import BufferedReader, BytesIO from pathlib import Path +from urllib.parse import urlparse, urlunparse from typing import Any, Dict, List, Optional, Union import requests @@ -831,8 +831,6 @@ def make_generic_api_request( # noqa: PLR0913 # pylint: disable=too-many-argume def _edge_base_url(self) -> str: """Return the scheme+host+port of the configured endpoint, without the /device-api path.""" - from urllib.parse import urlparse, urlunparse - parsed = urlparse(self.configuration.host) return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index a3e3b3db..6d16636e 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -326,10 +326,10 @@ def test_get_edge_config_parses_response(): mock_response.raise_for_status = Mock() gl = ExperimentalApi() - with patch("requests.get", return_value=mock_response) as mock_get: + with patch("requests.request", return_value=mock_response) as mock_request: config = gl.get_edge_config() - mock_get.assert_called_once() + mock_request.assert_called_once() assert isinstance(config, EdgeEndpointConfig) assert config.global_config.refresh_rate == REFRESH_RATE_SECONDS assert config.edge_inference_configs["default"].name == "default" From b0b6b8257ab903beb310408d391d02d5cc3b9146 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Thu, 26 Mar 2026 18:53:18 +0000 Subject: [PATCH 07/22] Automatically reformatting code --- src/groundlight/experimental_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 0844b016..f578ff56 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -10,8 +10,8 @@ import json from io import BufferedReader, BytesIO from pathlib import Path -from urllib.parse import urlparse, urlunparse from typing import Any, Dict, List, Optional, Union +from urllib.parse import urlparse, urlunparse import requests from groundlight_openapi_client.api.actions_api import ActionsApi From 9c45e06ae371cfac49341256271a9a6d62fdafb5 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Tue, 31 Mar 2026 17:05:11 +0000 Subject: [PATCH 08/22] bumping version --- pyproject.toml | 2 +- src/groundlight/experimental_api.py | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a0d1ca8e..b958ecff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ packages = [ {include = "**/*.py", from = "src"}, ] readme = "README.md" -version = "0.25.1" +version = "0.26.0" [tool.poetry.dependencies] # For certifi, use ">=" instead of "^" since it upgrades its "major version" every year, not really following semver diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 0844b016..edddc4ee 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -106,14 +106,7 @@ def __init__( self.detector_reset_api = DetectorResetApi(self.api_client) self.edge_api = EdgeApi(self.api_client) - self._edge: EdgeAPI | None = None - - @property - def edge(self) -> "EdgeAPI": - """Access edge-endpoint operations (e.g. ``gl.edge.get_config()``).""" - if self._edge is None: - self._edge = EdgeAPI(self) - return self._edge + self.edge = EdgeAPI(self) ITEMS_PER_PAGE = 100 @@ -845,7 +838,7 @@ def get_edge_detector_readiness(self) -> dict[str, bool]: def set_edge_config( self, config: EdgeEndpointConfig, - timeout_sec: float = 300, + timeout_sec: float = 600, ) -> EdgeEndpointConfig: """Deprecated: use ``gl.edge.set_config()`` instead.""" return self.edge.set_config(config, timeout_sec=timeout_sec) From 91e58397d818292202a8c5031b27efc9f4fea70a Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Tue, 31 Mar 2026 17:19:29 +0000 Subject: [PATCH 09/22] addressing linter errors and cleaning up code --- src/groundlight/edge/api.py | 4 ++-- src/groundlight/experimental_api.py | 19 +------------------ test/unit/test_edge_config.py | 6 +++--- 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index e0b24fe5..32864372 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -18,14 +18,14 @@ def __init__(self, client) -> None: self._client = client def _base_url(self) -> str: - return self._client._edge_base_url() + return self._client.edge_base_url() def _request(self, method: str, path: str, **kwargs) -> requests.Response: url = f"{self._base_url()}{path}" headers = self._client.get_raw_headers() try: response = requests.request( - method, url, headers=headers, verify=self._client.configuration.verify_ssl, **kwargs + method, url, headers=headers, verify=self._client.configuration.verify_ssl, timeout=10, **kwargs ) response.raise_for_status() except requests.exceptions.HTTPError as e: diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 37e51c67..bdfd4b0f 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -42,7 +42,6 @@ from urllib3.response import HTTPResponse from groundlight.edge.api import EdgeAPI -from groundlight.edge.config import EdgeEndpointConfig from groundlight.images import parse_supported_image_types from groundlight.internalapi import _generate_request_id from groundlight.optional_imports import Image, np @@ -822,23 +821,7 @@ def make_generic_api_request( # noqa: PLR0913 # pylint: disable=too-many-argume _preload_content=False, # This returns the urllib3 response rather than trying any type of processing ) - def _edge_base_url(self) -> str: + def edge_base_url(self) -> str: """Return the scheme+host+port of the configured endpoint, without the /device-api path.""" parsed = urlparse(self.configuration.host) return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) - - def get_edge_config(self) -> EdgeEndpointConfig: - """Deprecated: use ``gl.edge.get_config()`` instead.""" - return self.edge.get_config() - - def get_edge_detector_readiness(self) -> dict[str, bool]: - """Deprecated: use ``gl.edge.get_detector_readiness()`` instead.""" - return self.edge.get_detector_readiness() - - def set_edge_config( - self, - config: EdgeEndpointConfig, - timeout_sec: float = 600, - ) -> EdgeEndpointConfig: - """Deprecated: use ``gl.edge.set_config()`` instead.""" - return self.edge.set_config(config, timeout_sec=timeout_sec) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 6d16636e..530b3f49 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -309,8 +309,8 @@ def test_inference_config_validation_errors(): ) -def test_get_edge_config_parses_response(): - """ExperimentalApi.get_edge_config() parses the HTTP response into an EdgeEndpointConfig.""" +def test_edge_get_config_parses_response(): + """gl.edge.get_config() parses the HTTP response into an EdgeEndpointConfig.""" from unittest.mock import Mock, patch from groundlight import ExperimentalApi @@ -327,7 +327,7 @@ def test_get_edge_config_parses_response(): gl = ExperimentalApi() with patch("requests.request", return_value=mock_response) as mock_request: - config = gl.get_edge_config() + config = gl.edge.get_config() mock_request.assert_called_once() assert isinstance(config, EdgeEndpointConfig) From 8446a4e3547dbf2793b7e863f6e4af1f67d9a175 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Tue, 31 Mar 2026 17:27:22 +0000 Subject: [PATCH 10/22] fixing another linter issue --- src/groundlight/edge/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index 32864372..d349ec91 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -1,4 +1,5 @@ import time +from http import HTTPStatus import requests @@ -29,7 +30,7 @@ def _request(self, method: str, path: str, **kwargs) -> requests.Response: ) response.raise_for_status() except requests.exceptions.HTTPError as e: - if e.response is not None and e.response.status_code == 404: + if e.response is not None and e.response.status_code == HTTPStatus.NOT_FOUND: raise EdgeNotAvailableError( f"Edge method not available at {url}. {_EDGE_METHOD_UNAVAILABLE_HINT}" ) from e From 8d9464fa019f585c91cc67454d0a016861000244 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Tue, 31 Mar 2026 17:32:21 +0000 Subject: [PATCH 11/22] fixing another linter error --- src/groundlight/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 11783497..9e851449 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -72,8 +72,6 @@ class ApiTokenError(GroundlightClientError): class EdgeNotAvailableError(GroundlightClientError): """Raised when an edge-only method is called against a non-edge endpoint.""" - pass - class Groundlight: # pylint: disable=too-many-instance-attributes,too-many-public-methods """ From bce92fe6a9164368639b472e1bedd0ee381b409f Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 1 Apr 2026 00:07:10 +0000 Subject: [PATCH 12/22] fixing an edge case --- src/groundlight/edge/api.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index d349ec91..c23ba2be 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -67,14 +67,16 @@ def set_config( """ self._request("PUT", "/edge-config", json=config.to_payload()) - poll_interval_seconds = 1 desired_ids = {d.detector_id for d in config.detectors if d.detector_id} + if not desired_ids: + return self.get_config() + deadline = time.time() + timeout_sec while time.time() < deadline: readiness = self.get_detector_readiness() - if desired_ids and all(readiness.get(did, False) for did in desired_ids): + if all(readiness.get(did, False) for did in desired_ids): return self.get_config() - time.sleep(poll_interval_seconds) + time.sleep(1) raise TimeoutError( f"Edge detectors were not all ready within {timeout_sec}s. " From e96ea126bde449abf0ae079e5085151a560ee3d7 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 1 Apr 2026 00:38:58 +0000 Subject: [PATCH 13/22] adding pydantic validation --- src/groundlight/edge/config.py | 2 +- test/unit/test_edge_config.py | 83 ++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index 1b0de76d..d1280f97 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -78,7 +78,7 @@ class DetectorConfig(BaseModel): # pylint: disable=too-few-public-methods model_config = ConfigDict(extra="ignore") - detector_id: str = Field(..., description="Detector ID") + detector_id: str = Field(..., pattern=r"^det_[A-Za-z0-9]{27}$", description="Detector ID") edge_inference_config: str = Field(..., description="Config for edge inference.") diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 530b3f49..95ca8744 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -17,6 +17,11 @@ CUSTOM_AUDIT_RATE = 0.0 REFRESH_RATE_SECONDS = 15.0 +# Mock detector IDs +DET_1 = "det_000000000000000000000000001" +DET_2 = "det_000000000000000000000000002" +DET_3 = "det_000000000000000000000000003" + def _make_detector(detector_id: str) -> Detector: return Detector( @@ -36,7 +41,7 @@ def test_add_detector_allows_equivalent_named_inference_config(): """Allows reusing the same named inference config with equivalent values.""" detectors_config = DetectorsConfig() detectors_config.add_detector( - "det_1", + DET_1, InferenceConfig( name="custom_config", always_return_edge_prediction=True, @@ -44,7 +49,7 @@ def test_add_detector_allows_equivalent_named_inference_config(): ), ) detectors_config.add_detector( - "det_2", + DET_2, InferenceConfig( name="custom_config", always_return_edge_prediction=True, @@ -59,11 +64,11 @@ def test_add_detector_allows_equivalent_named_inference_config(): def test_add_detector_rejects_different_named_inference_config(): """Rejects conflicting inference config values under the same name.""" detectors_config = DetectorsConfig() - detectors_config.add_detector("det_1", InferenceConfig(name="custom_config")) + detectors_config.add_detector(DET_1, InferenceConfig(name="custom_config")) with pytest.raises(ValueError, match="different inference config named 'custom_config'"): detectors_config.add_detector( - "det_2", + DET_2, InferenceConfig(name="custom_config", always_return_edge_prediction=True), ) @@ -71,10 +76,10 @@ def test_add_detector_rejects_different_named_inference_config(): def test_add_detector_rejects_duplicate_detector_id(): """Rejects adding the same detector ID more than once.""" detectors_config = DetectorsConfig() - detectors_config.add_detector("det_1", DEFAULT) + detectors_config.add_detector(DET_1, DEFAULT) with pytest.raises(ValueError, match="already exists"): - detectors_config.add_detector("det_1", DEFAULT) + detectors_config.add_detector(DET_1, DEFAULT) def test_constructor_rejects_duplicate_detector_ids(): @@ -83,8 +88,8 @@ def test_constructor_rejects_duplicate_detector_ids(): DetectorsConfig( edge_inference_configs={"default": DEFAULT}, detectors=[ - {"detector_id": "det_1", "edge_inference_config": "default"}, - {"detector_id": "det_1", "edge_inference_config": "default"}, + {"detector_id": DET_1, "edge_inference_config": "default"}, + {"detector_id": DET_1, "edge_inference_config": "default"}, ], ) @@ -102,18 +107,18 @@ def test_constructor_accepts_matching_inference_config_key_and_name(): """Accepts constructor input when key/name pairs are consistent.""" config = DetectorsConfig( edge_inference_configs={"default": InferenceConfig(name="default")}, - detectors=[{"detector_id": "det_1", "edge_inference_config": "default"}], + detectors=[{"detector_id": DET_1, "edge_inference_config": "default"}], ) assert list(config.edge_inference_configs.keys()) == ["default"] - assert [detector.detector_id for detector in config.detectors] == ["det_1"] + assert [detector.detector_id for detector in config.detectors] == [DET_1] def test_constructor_hydrates_inference_config_name_from_dict_key(): """Hydrates inference config names from payload dict keys.""" config = DetectorsConfig( edge_inference_configs={"default": {"enabled": True}}, - detectors=[{"detector_id": "det_1", "edge_inference_config": "default"}], + detectors=[{"detector_id": DET_1, "edge_inference_config": "default"}], ) assert config.edge_inference_configs["default"].name == "default" @@ -124,7 +129,7 @@ def test_constructor_rejects_detector_map_input(): with pytest.raises(ValueError): DetectorsConfig( edge_inference_configs={"default": {"enabled": True}}, - detectors={"det_1": {"detector_id": "det_1", "edge_inference_config": "default"}}, + detectors={DET_1: {"detector_id": DET_1, "edge_inference_config": "default"}}, ) @@ -133,33 +138,33 @@ def test_constructor_rejects_undefined_inference_config_reference(): with pytest.raises(ValueError, match="not defined"): DetectorsConfig( edge_inference_configs={}, - detectors=[{"detector_id": "det_1", "edge_inference_config": "does_not_exist"}], + detectors=[{"detector_id": DET_1, "edge_inference_config": "does_not_exist"}], ) def test_edge_endpoint_config_add_detector_uses_shared_config_logic(): """Adds detectors via EdgeEndpointConfig and preserves inferred config mapping.""" config = EdgeEndpointConfig() - config.add_detector("det_1", NO_CLOUD) - config.add_detector("det_2", EDGE_ANSWERS_WITH_ESCALATION) - config.add_detector("det_3", DEFAULT) + config.add_detector(DET_1, NO_CLOUD) + config.add_detector(DET_2, EDGE_ANSWERS_WITH_ESCALATION) + config.add_detector(DET_3, DEFAULT) - assert [detector.detector_id for detector in config.detectors] == ["det_1", "det_2", "det_3"] + assert [detector.detector_id for detector in config.detectors] == [DET_1, DET_2, DET_3] assert set(config.edge_inference_configs.keys()) == {"no_cloud", "edge_answers_with_escalation", "default"} def test_add_detector_accepts_detector_object(): """Accepts Detector objects in add_detector.""" config = EdgeEndpointConfig() - config.add_detector(_make_detector("det_1"), DEFAULT) + config.add_detector(_make_detector(DET_1), DEFAULT) - assert [detector.detector_id for detector in config.detectors] == ["det_1"] + assert [detector.detector_id for detector in config.detectors] == [DET_1] def test_disabled_preset_can_be_used(): """Allows assigning the DISABLED inference preset to a detector.""" config = EdgeEndpointConfig() - config.add_detector("det_1", DISABLED) + config.add_detector(DET_1, DISABLED) assert [detector.edge_inference_config for detector in config.detectors] == ["disabled"] assert config.edge_inference_configs["disabled"] == DISABLED @@ -168,8 +173,8 @@ def test_disabled_preset_can_be_used(): def test_detectors_config_to_payload_shape(): """Serializes detector-scoped payload with expected top-level keys.""" detectors_config = DetectorsConfig() - detectors_config.add_detector("det_1", DEFAULT) - detectors_config.add_detector("det_2", NO_CLOUD) + detectors_config.add_detector(DET_1, DEFAULT) + detectors_config.add_detector(DET_2, NO_CLOUD) payload = detectors_config.to_payload() @@ -182,11 +187,11 @@ def test_edge_endpoint_config_accepts_top_level_payload_shape(): config = EdgeEndpointConfig.model_validate({ "global_config": {"refresh_rate": CUSTOM_REFRESH_RATE}, "edge_inference_configs": {"default": {"enabled": True}}, - "detectors": [{"detector_id": "det_1", "edge_inference_config": "default"}], + "detectors": [{"detector_id": DET_1, "edge_inference_config": "default"}], }) assert config.global_config.refresh_rate == CUSTOM_REFRESH_RATE - assert [detector.detector_id for detector in config.detectors] == ["det_1"] + assert [detector.detector_id for detector in config.detectors] == [DET_1] def test_edge_endpoint_config_from_yaml_accepts_yaml_text(): @@ -198,12 +203,12 @@ def test_edge_endpoint_config_from_yaml_accepts_yaml_text(): default: enabled: true detectors: - - detector_id: det_1 + - detector_id: {DET_1} edge_inference_config: default """) assert config.global_config.refresh_rate == REFRESH_RATE_SECONDS - assert [detector.detector_id for detector in config.detectors] == ["det_1"] + assert [detector.detector_id for detector in config.detectors] == [DET_1] def test_edge_endpoint_config_from_yaml_accepts_filename(tmp_path): @@ -215,12 +220,12 @@ def test_edge_endpoint_config_from_yaml_accepts_filename(tmp_path): " default:\n" " enabled: true\n" "detectors:\n" - " - detector_id: det_1\n" + f" - detector_id: {DET_1}\n" " edge_inference_config: default\n" ) config = EdgeEndpointConfig.from_yaml(filename=str(config_file)) - assert [detector.detector_id for detector in config.detectors] == ["det_1"] + assert [detector.detector_id for detector in config.detectors] == [DET_1] def test_edge_endpoint_config_from_yaml_requires_exactly_one_input(): @@ -243,13 +248,13 @@ def test_edge_endpoint_config_ignores_extra_fields_at_all_levels(): "default": {"enabled": True, "unknown_inference_field": 42}, }, "detectors": [ - {"detector_id": "det_1", "edge_inference_config": "default", "unknown_detector_field": [1, 2]}, + {"detector_id": DET_1, "edge_inference_config": "default", "unknown_detector_field": [1, 2]}, ], "unknown_top_level_field": True, }) assert config.global_config.refresh_rate == REFRESH_RATE_SECONDS assert config.edge_inference_configs["default"].enabled is True - assert config.detectors[0].detector_id == "det_1" + assert config.detectors[0].detector_id == DET_1 def test_model_dump_shape_for_edge_endpoint_config(): @@ -257,9 +262,9 @@ def test_model_dump_shape_for_edge_endpoint_config(): config = EdgeEndpointConfig( global_config=GlobalConfig(refresh_rate=CUSTOM_REFRESH_RATE, confident_audit_rate=CUSTOM_AUDIT_RATE) ) - config.add_detector("det_1", DEFAULT) - config.add_detector("det_2", EDGE_ANSWERS_WITH_ESCALATION) - config.add_detector("det_3", NO_CLOUD) + config.add_detector(DET_1, DEFAULT) + config.add_detector(DET_2, EDGE_ANSWERS_WITH_ESCALATION) + config.add_detector(DET_3, NO_CLOUD) payload = config.to_payload() @@ -272,8 +277,8 @@ def test_model_dump_shape_for_edge_endpoint_config(): def test_edge_endpoint_config_from_payload_round_trip(): """Round-trips edge endpoint config through payload helpers.""" config = EdgeEndpointConfig() - config.add_detector("det_1", DEFAULT) - config.add_detector("det_2", NO_CLOUD) + config.add_detector(DET_1, DEFAULT) + config.add_detector(DET_2, NO_CLOUD) payload = config.to_payload() reconstructed = EdgeEndpointConfig.from_payload(payload) @@ -286,14 +291,14 @@ def test_edge_endpoint_config_from_payload_accepts_literal_payload(): payload = { "global_config": {"refresh_rate": REFRESH_RATE_SECONDS}, "edge_inference_configs": {"default": {"enabled": True}}, - "detectors": [{"detector_id": "det_1", "edge_inference_config": "default"}], + "detectors": [{"detector_id": DET_1, "edge_inference_config": "default"}], } config = EdgeEndpointConfig.from_payload(payload) assert config.global_config.refresh_rate == REFRESH_RATE_SECONDS assert config.edge_inference_configs["default"].name == "default" - assert [detector.detector_id for detector in config.detectors] == ["det_1"] + assert [detector.detector_id for detector in config.detectors] == [DET_1] def test_inference_config_validation_errors(): @@ -318,7 +323,7 @@ def test_edge_get_config_parses_response(): payload = { "global_config": {"refresh_rate": REFRESH_RATE_SECONDS}, "edge_inference_configs": {"default": {"enabled": True}}, - "detectors": [{"detector_id": "det_1", "edge_inference_config": "default"}], + "detectors": [{"detector_id": DET_1, "edge_inference_config": "default"}], } mock_response = Mock() @@ -333,4 +338,4 @@ def test_edge_get_config_parses_response(): assert isinstance(config, EdgeEndpointConfig) assert config.global_config.refresh_rate == REFRESH_RATE_SECONDS assert config.edge_inference_configs["default"].name == "default" - assert [d.detector_id for d in config.detectors] == ["det_1"] + assert [d.detector_id for d in config.detectors] == [DET_1] From 89b083f39c69b7ff0b538fb80568584db8baabc9 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 2 Apr 2026 21:39:26 +0000 Subject: [PATCH 14/22] adjusting comment --- src/groundlight/edge/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index c23ba2be..e1d98d97 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -7,7 +7,7 @@ from groundlight.edge.config import EdgeEndpointConfig _EDGE_METHOD_UNAVAILABLE_HINT = ( - "Make sure the client is pointed at a running edge endpoint " + "Make sure the client is pointed at a running Edge Endpoint " "(via GROUNDLIGHT_ENDPOINT env var or the endpoint= constructor arg)." ) From 00198f2d190097c97d0ef8d956c270ed063643d5 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 2 Apr 2026 22:00:18 +0000 Subject: [PATCH 15/22] improving some names --- src/groundlight/edge/__init__.py | 4 ++-- src/groundlight/edge/api.py | 4 ++-- src/groundlight/experimental_api.py | 11 +++++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/groundlight/edge/__init__.py b/src/groundlight/edge/__init__.py index 85590995..0903a189 100644 --- a/src/groundlight/edge/__init__.py +++ b/src/groundlight/edge/__init__.py @@ -1,4 +1,4 @@ -from .api import EdgeAPI +from .api import EdgeEndpointApi from .config import ( DEFAULT, DISABLED, @@ -15,7 +15,7 @@ "DEFAULT", "DISABLED", "EDGE_ANSWERS_WITH_ESCALATION", - "EdgeAPI", + "EdgeEndpointApi", "NO_CLOUD", "DetectorsConfig", "DetectorConfig", diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index e1d98d97..5e57cd0c 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -12,8 +12,8 @@ ) -class EdgeAPI: - """Namespace for edge-endpoint operations, accessed via ``gl.edge``.""" +class EdgeEndpointApi: + """Namespace for operations that are specific to the Edge Endpoint, such as setting and getting the EdgeEndpoint configuration.""" def __init__(self, client) -> None: self._client = client diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index bdfd4b0f..a2071fa2 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -41,7 +41,7 @@ ) from urllib3.response import HTTPResponse -from groundlight.edge.api import EdgeAPI +from groundlight.edge.api import EdgeEndpointApi from groundlight.images import parse_supported_image_types from groundlight.internalapi import _generate_request_id from groundlight.optional_imports import Image, np @@ -104,8 +104,11 @@ def __init__( self.detector_group_api = DetectorGroupsApi(self.api_client) self.detector_reset_api = DetectorResetApi(self.api_client) - self.edge_api = EdgeApi(self.api_client) - self.edge = EdgeAPI(self) + # API client for fetching Edge models + self._edge_model_download_api = EdgeApi(self.api_client) + + # API client for interacting with the EdgeEndpoint (getting/setting configuration, etc.) + self.edge = EdgeEndpointApi(self) ITEMS_PER_PAGE = 100 @@ -707,7 +710,7 @@ def _download_mlbinary_url(self, detector: Union[str, Detector]) -> EdgeModelInf """ if isinstance(detector, Detector): detector = detector.id - obj = self.edge_api.get_model_urls(detector) + obj = self._edge_model_download_api.get_model_urls(detector) return EdgeModelInfo.parse_obj(obj.to_dict()) def download_mlbinary(self, detector: Union[str, Detector], output_dir: str) -> None: From 80b3ac5974d2fdbb58b79d3197fbc8bfd383b229 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 2 Apr 2026 22:06:43 +0000 Subject: [PATCH 16/22] fixing linter error --- src/groundlight/edge/api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index 5e57cd0c..1d4f60b1 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -13,7 +13,10 @@ class EdgeEndpointApi: - """Namespace for operations that are specific to the Edge Endpoint, such as setting and getting the EdgeEndpoint configuration.""" + """ + Namespace for operations that are specific to the Edge Endpoint, + such as setting and getting the EdgeEndpoint configuration. + """ def __init__(self, client) -> None: self._client = client From 2e393e95bd1b4808bc650c2aa3d72cc21beb4c40 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 2 Apr 2026 22:26:04 +0000 Subject: [PATCH 17/22] adding documentation --- docs/docs/guide/8-edge.md | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/docs/guide/8-edge.md b/docs/docs/guide/8-edge.md index 07d4f043..1d2f0fc5 100644 --- a/docs/docs/guide/8-edge.md +++ b/docs/docs/guide/8-edge.md @@ -38,6 +38,46 @@ python your_app.py In the above example, the `edge-endpoint` is running on the same machine as the application, so the endpoint URL is `http://localhost:30101`. If the `edge-endpoint` is running on a different machine, you should replace `localhost` with the IP address or hostname of the machine running the `edge-endpoint`. ::: +## Configuring the Edge Endpoint at Runtime + +:::note +Runtime edge configuration is currently in beta and available through the `ExperimentalApi`. +::: + +You can programmatically configure which detectors run on the edge and how they behave, without redeploying. + +This allows applications to define the desired state of the Edge Endpoint, thereby eliminating the need to manually configure the Edge Endpoint separately. + +```python notest +from groundlight import ExperimentalApi +from groundlight.edge import EdgeEndpointConfig +from groundlight.edge.config import NO_CLOUD, EDGE_ANSWERS_WITH_ESCALATION + +# Connect to an Edge Endpoint +gl = ExperimentalApi(endpoint="http://localhost:30101") + +# Build a configuration with detectors and inference presets +config = EdgeEndpointConfig() +config.add_detector("det_YOUR_DETECTOR_ID_HERE_01", NO_CLOUD) +config.add_detector("det_YOUR_DETECTOR_ID_HERE_02", DEFAULT) + +# Apply the configuration and wait for detectors to be ready +print("Applying configuration...") +config = gl.edge.set_config(config) +print(f"Applied config with {len(config.detectors)} detector(s)") +``` + +`set_config` replaces the current configuration and blocks until all detectors have inference pods ready to serve requests (or until the timeout expires). + +You can also inspect the current configuration: + +```python notest +# Retrieve the active configuration +config = gl.edge.get_config() +for det in config.detectors: + print(f" {det.detector_id} -> {det.edge_inference_config}") +``` + ## Edge Endpoint performance We have benchmarked the `edge-endpoint` handling 500 requests/sec at a latency of less than 50ms on an off-the-shelf [Katana 15 B13VGK-1007US](https://us.msi.com/Laptop/Katana-15-B13VX/Specification) laptop (Intel® Core™ i9-13900H CPU, NVIDIA® GeForce RTX™ 4070 Laptop GPU, 32GB DDR5 5200MHz RAM) running Ubuntu 20.04. From 8e576f426526dbc3062ab4f6dc7a3b07f3b8a0d9 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 2 Apr 2026 23:05:25 +0000 Subject: [PATCH 18/22] responding to pr feedback --- src/groundlight/edge/api.py | 2 +- test/unit/test_edge_config.py | 53 ++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index 1d4f60b1..553e0e26 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -70,7 +70,7 @@ def set_config( """ self._request("PUT", "/edge-config", json=config.to_payload()) - desired_ids = {d.detector_id for d in config.detectors if d.detector_id} + desired_ids = {d.detector_id for d in config.detectors} if not desired_ids: return self.get_config() diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 95ca8744..b2d9db66 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,6 +1,8 @@ from datetime import datetime, timezone +from unittest.mock import Mock, patch import pytest +from groundlight import ExperimentalApi from groundlight.edge import ( DEFAULT, DISABLED, @@ -316,10 +318,6 @@ def test_inference_config_validation_errors(): def test_edge_get_config_parses_response(): """gl.edge.get_config() parses the HTTP response into an EdgeEndpointConfig.""" - from unittest.mock import Mock, patch - - from groundlight import ExperimentalApi - payload = { "global_config": {"refresh_rate": REFRESH_RATE_SECONDS}, "edge_inference_configs": {"default": {"enabled": True}}, @@ -339,3 +337,50 @@ def test_edge_get_config_parses_response(): assert config.global_config.refresh_rate == REFRESH_RATE_SECONDS assert config.edge_inference_configs["default"].name == "default" assert [d.detector_id for d in config.detectors] == [DET_1] + + +def test_edge_set_config_sends_payload_and_polls(): + """gl.edge.set_config() PUTs the config then polls readiness until all detectors are ready.""" + config = EdgeEndpointConfig() + config.add_detector(DET_1, DEFAULT) + + put_response = Mock() + put_response.raise_for_status = Mock() + + readiness_response = Mock() + readiness_response.json.return_value = {DET_1: {"ready": True}} + readiness_response.raise_for_status = Mock() + + get_response = Mock() + get_response.json.return_value = config.to_payload() + get_response.raise_for_status = Mock() + + def route_request(method, url, **kwargs): + if method == "PUT": + return put_response + if "/edge-detector-readiness" in url: + return readiness_response + return get_response + + gl = ExperimentalApi() + with patch("requests.request", side_effect=route_request): + result = gl.edge.set_config(config) + + assert isinstance(result, EdgeEndpointConfig) + assert [d.detector_id for d in result.detectors] == [DET_1] + + +def test_edge_get_detector_readiness(): + """gl.edge.get_detector_readiness() returns a dict mapping detector IDs to booleans.""" + mock_response = Mock() + mock_response.json.return_value = { + DET_1: {"ready": True}, + DET_2: {"ready": False}, + } + mock_response.raise_for_status = Mock() + + gl = ExperimentalApi() + with patch("requests.request", return_value=mock_response): + readiness = gl.edge.get_detector_readiness() + + assert readiness == {DET_1: True, DET_2: False} From 21282baa92f45c95eec2a4cb87c673c2119338c5 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 3 Apr 2026 17:25:55 +0000 Subject: [PATCH 19/22] adding more pydantic validation --- src/groundlight/edge/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index d1280f97..080bdcee 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -13,10 +13,12 @@ class GlobalConfig(BaseModel): # pylint: disable=too-few-public-methods refresh_rate: float = Field( default=60.0, + gt=0, description="The interval (in seconds) at which the inference server checks for a new model binary update.", ) confident_audit_rate: float = Field( default=1e-5, # A detector running at 1 FPS = ~100,000 IQ/day, so 1e-5 is ~1 confident IQ/day audited + gt=0, description="The probability that any given confident prediction will be sent to the cloud for auditing.", ) @@ -29,7 +31,7 @@ class InferenceConfig(BaseModel): # pylint: disable=too-few-public-methods # Keep shared presets immutable (DEFAULT/NO_CLOUD/etc.) so one mutation cannot globally change behavior. model_config = ConfigDict(extra="ignore", frozen=True) - name: str = Field(..., exclude=True, description="A unique name for this inference config preset.") + name: str = Field(..., min_length=1, exclude=True, description="A unique name for this inference config preset.") enabled: bool = Field( default=True, description="Whether the edge endpoint should accept image queries for this detector." ) @@ -53,9 +55,9 @@ class InferenceConfig(BaseModel): # pylint: disable=too-few-public-methods ) min_time_between_escalations: float = Field( default=2.0, + gt=0, description=( "The minimum time (in seconds) to wait between cloud escalations for a given detector. " - "Cannot be less than 0.0. " "Only applies when `always_return_edge_prediction=True` and `disable_cloud_escalation=False`." ), ) @@ -66,8 +68,6 @@ def validate_configuration(self) -> Self: raise ValueError( "The `disable_cloud_escalation` flag is only valid when `always_return_edge_prediction` is set to True." ) - if self.min_time_between_escalations < 0.0: - raise ValueError("`min_time_between_escalations` cannot be less than 0.0.") return self From 5a4062d22059d11cd696cb02eadbc6c754eaa136 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 3 Apr 2026 19:23:13 +0000 Subject: [PATCH 20/22] fixing a test --- src/groundlight/edge/config.py | 2 +- test/unit/test_edge_config.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index 080bdcee..88678cec 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -18,7 +18,7 @@ class GlobalConfig(BaseModel): # pylint: disable=too-few-public-methods ) confident_audit_rate: float = Field( default=1e-5, # A detector running at 1 FPS = ~100,000 IQ/day, so 1e-5 is ~1 confident IQ/day audited - gt=0, + ge=0, description="The probability that any given confident prediction will be sent to the cloud for auditing.", ) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index b2d9db66..188495c9 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -2,6 +2,8 @@ from unittest.mock import Mock, patch import pytest +from pydantic import ValidationError + from groundlight import ExperimentalApi from groundlight.edge import ( DEFAULT, @@ -308,7 +310,7 @@ def test_inference_config_validation_errors(): with pytest.raises(ValueError, match="disable_cloud_escalation"): InferenceConfig(name="bad", disable_cloud_escalation=True) - with pytest.raises(ValueError, match="cannot be less than 0.0"): + with pytest.raises(ValidationError, match="greater_than"): InferenceConfig( name="bad_escalation_interval", always_return_edge_prediction=True, From bc3828f71066b47d1fdcf9368651d7082faf2191 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Fri, 3 Apr 2026 19:23:59 +0000 Subject: [PATCH 21/22] Automatically reformatting code --- test/unit/test_edge_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 188495c9..59a1157d 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -2,8 +2,6 @@ from unittest.mock import Mock, patch import pytest -from pydantic import ValidationError - from groundlight import ExperimentalApi from groundlight.edge import ( DEFAULT, @@ -16,6 +14,7 @@ InferenceConfig, ) from model import Detector, DetectorTypeEnum +from pydantic import ValidationError CUSTOM_REFRESH_RATE = 10.0 CUSTOM_AUDIT_RATE = 0.0 From 5da4875327c19101c789a3a59c01e3a56d30f90c Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 3 Apr 2026 19:35:41 +0000 Subject: [PATCH 22/22] adjusting test and documentation --- docs/docs/guide/8-edge.md | 2 +- test/unit/test_edge_config.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/docs/guide/8-edge.md b/docs/docs/guide/8-edge.md index 1d2f0fc5..38a15ae8 100644 --- a/docs/docs/guide/8-edge.md +++ b/docs/docs/guide/8-edge.md @@ -59,7 +59,7 @@ gl = ExperimentalApi(endpoint="http://localhost:30101") # Build a configuration with detectors and inference presets config = EdgeEndpointConfig() config.add_detector("det_YOUR_DETECTOR_ID_HERE_01", NO_CLOUD) -config.add_detector("det_YOUR_DETECTOR_ID_HERE_02", DEFAULT) +config.add_detector("det_YOUR_DETECTOR_ID_HERE_02", EDGE_ANSWERS_WITH_ESCALATION) # Apply the configuration and wait for detectors to be ready print("Applying configuration...") diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 188495c9..b67d6287 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -318,6 +318,12 @@ def test_inference_config_validation_errors(): ) +def test_confident_audit_rate_allows_zero(): + """Zero is a valid confident_audit_rate (disables auditing).""" + gc = GlobalConfig(confident_audit_rate=0.0) + assert gc.confident_audit_rate == 0.0 + + def test_edge_get_config_parses_response(): """gl.edge.get_config() parses the HTTP response into an EdgeEndpointConfig.""" payload = {