Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ python = ">=3.9,<4.0"
python-dateutil = "^2.9.0"
requests = "^2.28.2"
typer = "^0.15.4"
urllib3 = "^1.26.9"
urllib3 = "^2.5.0"
Copy link
Contributor Author

@CoreyEWood CoreyEWood Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This upgrade is necessary for the retries parameter on the Configuration to actually be respected when requests are made. 2.5.0 is the most recent release - likely the fix was made earlier, but I don't see a reason not to update to the most recent (let me know if you know of a reason not to upgrade this, though).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a nasty debug for you. The upgrade makes perfect sense

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was indeed a hairy process!


[tool.poetry.group.dev.dependencies]
datamodel-code-generator = "^0.22.1"
Expand Down
35 changes: 24 additions & 11 deletions src/groundlight/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
PaginatedImageQueryList,
)
from urllib3.exceptions import InsecureRequestWarning
from urllib3.util.retry import Retry

from groundlight.binary_labels import Label, convert_internal_label_to_display
from groundlight.config import API_TOKEN_MISSING_HELP_MESSAGE, API_TOKEN_VARIABLE_NAME, DISABLE_TLS_VARIABLE_NAME
Expand Down Expand Up @@ -133,26 +134,31 @@ def __init__(
endpoint: Optional[str] = None,
api_token: Optional[str] = None,
disable_tls_verification: Optional[bool] = None,
http_transport_retries: Optional[Union[int, Retry]] = None,
):
"""
Initialize a new Groundlight client instance.

:param endpoint: Optional custom API endpoint URL. If not specified, uses the default Groundlight endpoint.
:param api_token: Authentication token for API access.
If not provided, will attempt to read from the "GROUNDLIGHT_API_TOKEN" environment variable.
If not provided, will attempt to read from the "GROUNDLIGHT_API_TOKEN" environment variable.
:param disable_tls_verification: If True, disables SSL/TLS certificate verification for API calls.
When not specified, checks the "DISABLE_TLS_VERIFY" environment variable
(1=disable, 0=enable). Certificate verification is enabled by default.
When not specified, checks the "DISABLE_TLS_VERIFY" environment variable (1=disable, 0=enable).
Certificate verification is enabled by default.

Warning: Only disable verification when connecting to a Groundlight Edge
Endpoint using self-signed certificates. For security, always keep
verification enabled when using the Groundlight cloud service.
Warning: Only disable verification when connecting to a Groundlight Edge Endpoint using self-signed
certificates. For security, always keep verification enabled when using the Groundlight cloud service.
Comment on lines +144 to +150
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just corrected the formatting on these.

:param http_transport_retries: Overrides urllib3 `PoolManager` retry policy for HTTP/HTTPS (forwarded to
`Configuration.retries`). Not the same as SDK 5xx retries handled by `RequestsRetryDecorator`.

:return: Groundlight client
"""
# Specify the endpoint
self.endpoint = sanitize_endpoint_url(endpoint)
self.configuration = Configuration(host=self.endpoint)
if http_transport_retries is not None:
# Once we upgrade openapitools to ^7.7.0, retries can be passed into the constructor of Configuration above.
self.configuration.retries = http_transport_retries

if not api_token:
try:
Expand Down Expand Up @@ -264,7 +270,11 @@ def _user_is_privileged(self) -> bool:
obj = self.user_api.who_am_i()
return obj["is_superuser"]

def get_detector(self, id: Union[str, Detector]) -> Detector: # pylint: disable=redefined-builtin
def get_detector(
self,
id: Union[str, Detector], # pylint: disable=redefined-builtin
request_timeout: Optional[Union[float, Tuple[float, float]]] = None,
) -> Detector:
"""
Get a Detector by id.

Expand All @@ -275,6 +285,8 @@ def get_detector(self, id: Union[str, Detector]) -> Detector: # pylint: disable
print(detector)

:param id: the detector id
:param request_timeout: The request timeout for the image query submission API request. Most users will not need
to modify this. If not set, the default value will be used.

:return: Detector
"""
Expand All @@ -283,7 +295,8 @@ def get_detector(self, id: Union[str, Detector]) -> Detector: # pylint: disable
# Short-circuit
return id
try:
obj = self.detectors_api.get_detector(id=id, _request_timeout=DEFAULT_REQUEST_TIMEOUT)
request_timeout = request_timeout if request_timeout is not None else DEFAULT_REQUEST_TIMEOUT
obj = self.detectors_api.get_detector(id=id, _request_timeout=request_timeout)
except NotFoundException as e:
raise NotFoundError(f"Detector with id '{id}' not found") from e
return Detector.parse_obj(obj.to_dict())
Expand Down Expand Up @@ -662,7 +675,7 @@ def submit_image_query( # noqa: PLR0913 # pylint: disable=too-many-arguments, t
inspection_id: Optional[str] = None,
metadata: Union[dict, str, None] = None,
image_query_id: Optional[str] = None,
request_timeout: Optional[float] = None,
request_timeout: Optional[Union[float, Tuple[float, float]]] = None,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's a Tuple[float, float] do here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request timeout has two components, the "connect timeout" and the "read timeout". If you supply a single number, that will be the value for the total timeout between both of those. If you supply a tuple with two values, it'll use those values as (connect_timeout, read_timeout).

) -> ImageQuery:
"""
Evaluates an image with Groundlight. This is the core method for getting predictions about images.
Expand Down Expand Up @@ -744,8 +757,8 @@ def submit_image_query( # noqa: PLR0913 # pylint: disable=too-many-arguments, t
:param image_query_id: The ID for the image query. This is to enable specific functionality
and is not intended for general external use. If not set, a random ID
will be generated.
:param request_timeout: The total request timeout for the image query submission API request. Most users will
not need to modify this. If not set, the default value will be used.
:param request_timeout: The request timeout for the image query submission API request. Most users will not need
to modify this. If not set, the default value will be used.

:return: ImageQuery with query details and result (if wait > 0)
:raises ValueError: If wait > 0 when want_async=True
Expand Down
43 changes: 39 additions & 4 deletions test/integration/test_groundlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
PaginatedDetectorList,
PaginatedImageQueryList,
)
from urllib3.exceptions import ReadTimeoutError
from urllib3.exceptions import ConnectTimeoutError, MaxRetryError, ReadTimeoutError
from urllib3.util.retry import Retry

DEFAULT_CONFIDENCE_THRESHOLD = 0.9
IQ_IMPROVEMENT_THRESHOLD = 0.75
Expand Down Expand Up @@ -80,6 +81,21 @@ def fixture_image() -> str:
return "test/assets/dog.jpeg"


def test_create_groundlight_with_retries():
"""Verify that the `retries` parameter can be successfully passed to the `Groundlight` constructor."""
# Set retries using int value
num_retries = 25
gl = Groundlight(http_transport_retries=num_retries)
assert gl.configuration.retries == num_retries
assert gl.api_client.configuration.retries == num_retries

# Set retries using Retry object
retries = Retry(total=num_retries)
gl = Groundlight(http_transport_retries=retries)
assert gl.configuration.retries.total == retries.total
assert gl.api_client.configuration.retries.total == retries.total


def test_create_detector(gl: Groundlight):
name = f"Test {datetime.utcnow()}" # Need a unique name
query = "Is there a dog?"
Expand Down Expand Up @@ -222,6 +238,21 @@ def test_get_detector(gl: Groundlight, detector: Detector):
assert isinstance(_detector, Detector)


def test_get_detector_with_low_request_timeout(gl: Groundlight, detector: Detector):
"""
Verifies that get_detector respects the request_timeout parameter and raises a MaxRetryError when timeout is
low. Verifies that request_timeout parameter can be a float or a tuple.
"""
with pytest.raises(MaxRetryError):
# Setting a very low request_timeout value should result in a timeout.
# NOTE: request_timeout=0 seems to have special behavior that does not result in a timeout.
gl.get_detector(id=detector.id, request_timeout=1e-8)

with pytest.raises(MaxRetryError):
# Ensure a tuple can be passed.
gl.get_detector(id=detector.id, request_timeout=(1e-8, 1e-8))


def test_get_detector_by_name(gl: Groundlight, detector: Detector):
_detector = gl.get_detector_by_name(name=detector.name)
assert str(_detector)
Expand Down Expand Up @@ -352,14 +383,18 @@ def test_submit_image_query_with_human_review_param(gl: Groundlight, detector: D

def test_submit_image_query_with_low_request_timeout(gl: Groundlight, detector: Detector, image: str):
"""
Test that submit_image_query respects the request_timeout parameter and raises a ReadTimeoutError when timeout is
exceeded.
Verifies that submit_image_query respects the request_timeout parameter and raises a ConnectTimeoutError or
ReadTimeoutError when timeout is low. Verifies that request_timeout parameter can be a float or a tuple.
"""
with pytest.raises(ReadTimeoutError):
with pytest.raises((ConnectTimeoutError, ReadTimeoutError)):
# Setting a very low request_timeout value should result in a timeout.
# NOTE: request_timeout=0 seems to have special behavior that does not result in a timeout.
gl.submit_image_query(detector=detector, image=image, human_review="NEVER", request_timeout=1e-8)

with pytest.raises((ConnectTimeoutError, ReadTimeoutError)):
# Ensure a tuple can be passed.
gl.submit_image_query(detector=detector, image=image, human_review="NEVER", request_timeout=(5, 1e-8))


@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint does not support passing detector metadata.")
def test_create_detector_with_metadata(gl: Groundlight):
Expand Down
Loading