Skip to content

Commit 237c39f

Browse files
committed
implementing the gl.edge pattern
1 parent b3f35a2 commit 237c39f

File tree

5 files changed

+102
-56
lines changed

5 files changed

+102
-56
lines changed

src/groundlight/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
# Imports from our code
99
from .client import Groundlight
10-
from .client import GroundlightClientError, ApiTokenError, NotFoundError
10+
from .client import GroundlightClientError, ApiTokenError, EdgeNotAvailableError, NotFoundError
1111
from .experimental_api import ExperimentalApi
1212
from .binary_labels import Label
1313
from .version import get_version

src/groundlight/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ class ApiTokenError(GroundlightClientError):
6969
pass
7070

7171

72+
class EdgeNotAvailableError(GroundlightClientError):
73+
"""Raised when an edge-only method is called against a non-edge endpoint."""
74+
75+
pass
76+
77+
7278
class Groundlight: # pylint: disable=too-many-instance-attributes,too-many-public-methods
7379
"""
7480
Client for accessing the Groundlight cloud service. Provides methods to create visual detectors,

src/groundlight/edge/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from .api import EdgeAPI
12
from .config import (
23
DEFAULT,
34
DISABLED,
@@ -14,6 +15,7 @@
1415
"DEFAULT",
1516
"DISABLED",
1617
"EDGE_ANSWERS_WITH_ESCALATION",
18+
"EdgeAPI",
1719
"NO_CLOUD",
1820
"DetectorsConfig",
1921
"DetectorConfig",

src/groundlight/edge/api.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import time
2+
3+
import requests
4+
5+
from groundlight.client import EdgeNotAvailableError
6+
from groundlight.edge.config import EdgeEndpointConfig
7+
8+
9+
_EDGE_METHOD_UNAVAILABLE_HINT = (
10+
"Make sure the client is pointed at a running edge endpoint "
11+
"(via GROUNDLIGHT_ENDPOINT env var or the endpoint= constructor arg)."
12+
)
13+
14+
15+
class EdgeAPI:
16+
"""Namespace for edge-endpoint operations, accessed via ``gl.edge``."""
17+
18+
def __init__(self, client) -> None:
19+
self._client = client
20+
21+
def _base_url(self) -> str:
22+
return self._client._edge_base_url()
23+
24+
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
25+
url = f"{self._base_url()}{path}"
26+
headers = self._client.get_raw_headers()
27+
try:
28+
response = requests.request(
29+
method, url, headers=headers, verify=self._client.configuration.verify_ssl, **kwargs
30+
)
31+
response.raise_for_status()
32+
except requests.exceptions.HTTPError as e:
33+
if e.response is not None and e.response.status_code == 404:
34+
raise EdgeNotAvailableError(f"Edge method not available at {url}. {_EDGE_METHOD_UNAVAILABLE_HINT}") from e
35+
raise
36+
except requests.exceptions.ConnectionError as e:
37+
raise EdgeNotAvailableError(f"Could not connect to {self._base_url()}. {_EDGE_METHOD_UNAVAILABLE_HINT}") from e
38+
return response
39+
40+
def get_config(self) -> EdgeEndpointConfig:
41+
"""Retrieve the active edge endpoint configuration."""
42+
response = self._request("GET", "/edge-config")
43+
return EdgeEndpointConfig.from_payload(response.json())
44+
45+
def get_detector_readiness(self) -> dict[str, bool]:
46+
"""Check which configured detectors have inference pods ready to serve.
47+
48+
:return: Dict mapping detector_id to readiness (True/False).
49+
"""
50+
response = self._request("GET", "/edge-detector-readiness")
51+
return {det_id: info["ready"] for det_id, info in response.json().items()}
52+
53+
def set_config(
54+
self,
55+
config: EdgeEndpointConfig,
56+
timeout_sec: float = 600,
57+
) -> EdgeEndpointConfig:
58+
"""Replace the edge endpoint configuration and wait until all detectors are ready.
59+
60+
:param config: The new configuration to apply.
61+
:param timeout_sec: Max seconds to wait for all detectors to become ready.
62+
:return: The applied configuration as reported by the edge endpoint.
63+
"""
64+
self._request("PUT", "/edge-config", json=config.to_payload())
65+
66+
poll_interval_seconds = 1
67+
desired_ids = {d.detector_id for d in config.detectors if d.detector_id}
68+
deadline = time.time() + timeout_sec
69+
while time.time() < deadline:
70+
readiness = self.get_detector_readiness()
71+
if desired_ids and all(readiness.get(did, False) for did in desired_ids):
72+
return self.get_config()
73+
time.sleep(poll_interval_seconds)
74+
75+
raise TimeoutError(
76+
f"Edge detectors were not all ready within {timeout_sec}s. "
77+
"The edge endpoint may still be converging, or may have encountered an error."
78+
)

src/groundlight/experimental_api.py

Lines changed: 15 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
)
4242
from urllib3.response import HTTPResponse
4343

44+
from groundlight.edge.api import EdgeAPI
4445
from groundlight.edge.config import EdgeEndpointConfig
4546
from groundlight.images import parse_supported_image_types
4647
from groundlight.internalapi import _generate_request_id
@@ -105,6 +106,14 @@ def __init__(
105106
self.detector_reset_api = DetectorResetApi(self.api_client)
106107

107108
self.edge_api = EdgeApi(self.api_client)
109+
self._edge: EdgeAPI | None = None
110+
111+
@property
112+
def edge(self) -> "EdgeAPI":
113+
"""Access edge-endpoint operations (e.g. ``gl.edge.get_config()``)."""
114+
if self._edge is None:
115+
self._edge = EdgeAPI(self)
116+
return self._edge
108117

109118
ITEMS_PER_PAGE = 100
110119

@@ -828,66 +837,17 @@ def _edge_base_url(self) -> str:
828837
return urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))
829838

830839
def get_edge_config(self) -> EdgeEndpointConfig:
831-
"""Retrieve the active edge endpoint configuration.
832-
833-
Only works when the client is pointed at an edge endpoint
834-
(via GROUNDLIGHT_ENDPOINT or the endpoint constructor arg).
835-
"""
836-
url = f"{self._edge_base_url()}/edge-config"
837-
headers = self.get_raw_headers()
838-
response = requests.get(url, headers=headers, verify=self.configuration.verify_ssl)
839-
response.raise_for_status()
840-
return EdgeEndpointConfig.from_payload(response.json())
840+
"""Deprecated: use ``gl.edge.get_config()`` instead."""
841+
return self.edge.get_config()
841842

842843
def get_edge_detector_readiness(self) -> dict[str, bool]:
843-
"""Check which configured detectors have inference pods ready to serve.
844-
845-
Only works when the client is pointed at an edge endpoint.
846-
847-
:return: Dict mapping detector_id to readiness (True/False).
848-
"""
849-
url = f"{self._edge_base_url()}/edge-detector-readiness"
850-
headers = self.get_raw_headers()
851-
response = requests.get(url, headers=headers, verify=self.configuration.verify_ssl)
852-
response.raise_for_status()
853-
return {det_id: info["ready"] for det_id, info in response.json().items()}
844+
"""Deprecated: use ``gl.edge.get_detector_readiness()`` instead."""
845+
return self.edge.get_detector_readiness()
854846

855847
def set_edge_config(
856848
self,
857849
config: EdgeEndpointConfig,
858-
mode: str = "REPLACE",
859850
timeout_sec: float = 300,
860-
poll_interval_sec: float = 1,
861851
) -> EdgeEndpointConfig:
862-
"""Send a new edge endpoint configuration and wait until all detectors are ready.
863-
864-
Only works when the client is pointed at an edge endpoint.
865-
866-
:param config: The new configuration to apply.
867-
:param mode: Currently only "REPLACE" is supported.
868-
:param timeout_sec: Max seconds to wait for all detectors to become ready.
869-
:param poll_interval_sec: How often to poll readiness while waiting.
870-
:return: The applied configuration as reported by the edge endpoint.
871-
"""
872-
if mode != "REPLACE":
873-
raise ValueError(f"Unsupported mode: {mode!r}. Currently only 'REPLACE' is supported.")
874-
875-
url = f"{self._edge_base_url()}/edge-config"
876-
headers = self.get_raw_headers()
877-
response = requests.put(
878-
url, json=config.to_payload(), headers=headers, verify=self.configuration.verify_ssl
879-
)
880-
response.raise_for_status()
881-
882-
desired_ids = {d.detector_id for d in config.detectors if d.detector_id}
883-
deadline = time.time() + timeout_sec
884-
while time.time() < deadline:
885-
readiness = self.get_edge_detector_readiness()
886-
if desired_ids and all(readiness.get(did, False) for did in desired_ids):
887-
return self.get_edge_config()
888-
time.sleep(poll_interval_sec)
889-
890-
raise TimeoutError(
891-
f"Edge detectors were not all ready within {timeout_sec}s. "
892-
"The edge endpoint may still be converging."
893-
)
852+
"""Deprecated: use ``gl.edge.set_config()`` instead."""
853+
return self.edge.set_config(config, timeout_sec=timeout_sec)

0 commit comments

Comments
 (0)