From 157ea544138f4c45542c11a681c910305881e178 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 20 Mar 2026 23:57:21 +0000 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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." )