From 6a16f78814b0a4bc77cecf43b7424b4bac421917 Mon Sep 17 00:00:00 2001 From: CoreyEWood Date: Tue, 23 Sep 2025 23:02:59 +0000 Subject: [PATCH 1/9] Increase urllib3 version to min 2.5.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c2a56583..c0bd997d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" [tool.poetry.group.dev.dependencies] datamodel-code-generator = "^0.22.1" From ea19514ca0f12ea89d4df2b6104d1d54668f2a46 Mon Sep 17 00:00:00 2001 From: CoreyEWood Date: Wed, 24 Sep 2025 21:40:21 +0000 Subject: [PATCH 2/9] Add http_transport_retries parameter to Groundlight class --- src/groundlight/client.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 06acdcf4..8b06c1ba 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -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 @@ -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. + :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: From 5f03befb7e9d8012d3a2ce64900b58dd478d3f39 Mon Sep 17 00:00:00 2001 From: CoreyEWood Date: Wed, 24 Sep 2025 22:07:07 +0000 Subject: [PATCH 3/9] Add request_timeout param to get_detector and add tests --- src/groundlight/client.py | 15 +++++++++++---- test/integration/test_groundlight.py | 23 +++++++++++++++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 8b06c1ba..dc6a53d2 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -270,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], + request_timeout: Optional[float] = None, + ) -> Detector: # pylint: disable=redefined-builtin """ Get a Detector by id. @@ -281,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 """ @@ -289,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()) @@ -750,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 diff --git a/test/integration/test_groundlight.py b/test/integration/test_groundlight.py index c0c49acf..ad94af1e 100644 --- a/test/integration/test_groundlight.py +++ b/test/integration/test_groundlight.py @@ -222,6 +222,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 ReadTimeoutError when timeout is + exceeded. Verifies that request_timeout parameter can be a float or a tuple. + """ + with pytest.raises(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.get_detector(id=detector.id, request_timeout=1e-8) + + with pytest.raises(ReadTimeoutError): + # 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) @@ -352,14 +367,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 ReadTimeoutError when timeout is + exceeded. Verifies that request_timeout parameter can be a float or a tuple. """ with pytest.raises(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(ReadTimeoutError): + # Ensure a tuple can be passed. + gl.submit_image_query(detector=detector, image=image, human_review="NEVER", request_timeout=(1e-8, 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): From 976ba03f895c2a3236415570b05b445e662fab59 Mon Sep 17 00:00:00 2001 From: CoreyEWood Date: Wed, 24 Sep 2025 22:46:53 +0000 Subject: [PATCH 4/9] fix typing, update tests, add test for retries --- src/groundlight/client.py | 4 ++-- test/integration/test_groundlight.py | 26 +++++++++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index dc6a53d2..75d62441 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -273,7 +273,7 @@ def _user_is_privileged(self) -> bool: def get_detector( self, id: Union[str, Detector], - request_timeout: Optional[float] = None, + request_timeout: Optional[Union[float, Tuple[float, float]]] = None, ) -> Detector: # pylint: disable=redefined-builtin """ Get a Detector by id. @@ -675,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, ) -> ImageQuery: """ Evaluates an image with Groundlight. This is the core method for getting predictions about images. diff --git a/test/integration/test_groundlight.py b/test/integration/test_groundlight.py index ad94af1e..b1a10478 100644 --- a/test/integration/test_groundlight.py +++ b/test/integration/test_groundlight.py @@ -27,7 +27,8 @@ PaginatedDetectorList, PaginatedImageQueryList, ) -from urllib3.exceptions import ReadTimeoutError +from urllib3.exceptions import MaxRetryError +from urllib3.util.retry import Retry DEFAULT_CONFIDENCE_THRESHOLD = 0.9 IQ_IMPROVEMENT_THRESHOLD = 0.75 @@ -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(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(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?" @@ -227,12 +243,12 @@ def test_get_detector_with_low_request_timeout(gl: Groundlight, detector: Detect Verifies that get_detector respects the request_timeout parameter and raises a ReadTimeoutError when timeout is exceeded. Verifies that request_timeout parameter can be a float or a tuple. """ - with pytest.raises(ReadTimeoutError): + 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(ReadTimeoutError): + with pytest.raises(MaxRetryError): # Ensure a tuple can be passed. gl.get_detector(id=detector.id, request_timeout=(1e-8, 1e-8)) @@ -370,12 +386,12 @@ def test_submit_image_query_with_low_request_timeout(gl: Groundlight, detector: Verifies that submit_image_query respects the request_timeout parameter and raises a ReadTimeoutError when timeout is exceeded. Verifies that request_timeout parameter can be a float or a tuple. """ - with pytest.raises(ReadTimeoutError): + 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.submit_image_query(detector=detector, image=image, human_review="NEVER", request_timeout=1e-8) - with pytest.raises(ReadTimeoutError): + with pytest.raises(MaxRetryError): # Ensure a tuple can be passed. gl.submit_image_query(detector=detector, image=image, human_review="NEVER", request_timeout=(1e-8, 1e-8)) From 1f7f04509e625e3860db33629b2063860e4cb723 Mon Sep 17 00:00:00 2001 From: CoreyEWood Date: Wed, 24 Sep 2025 22:56:48 +0000 Subject: [PATCH 5/9] move lint disabler --- src/groundlight/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 75d62441..f8908a27 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -272,9 +272,9 @@ def _user_is_privileged(self) -> bool: def get_detector( self, - id: Union[str, Detector], + id: Union[str, Detector], # pylint: disable=redefined-builtin request_timeout: Optional[Union[float, Tuple[float, float]]] = None, - ) -> Detector: # pylint: disable=redefined-builtin + ) -> Detector: """ Get a Detector by id. From 459bcb93b5a048539bb797143cb7f95e6f0a1800 Mon Sep 17 00:00:00 2001 From: CoreyEWood Date: Wed, 24 Sep 2025 23:00:39 +0000 Subject: [PATCH 6/9] test fixes --- test/integration/test_groundlight.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/integration/test_groundlight.py b/test/integration/test_groundlight.py index b1a10478..aa97ef68 100644 --- a/test/integration/test_groundlight.py +++ b/test/integration/test_groundlight.py @@ -27,7 +27,7 @@ PaginatedDetectorList, PaginatedImageQueryList, ) -from urllib3.exceptions import MaxRetryError +from urllib3.exceptions import MaxRetryError, ReadTimeoutError from urllib3.util.retry import Retry DEFAULT_CONFIDENCE_THRESHOLD = 0.9 @@ -85,13 +85,13 @@ 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(retries=num_retries) + 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(retries=retries) + gl = Groundlight(http_transport_retries=retries) assert gl.configuration.retries.total == retries.total assert gl.api_client.configuration.retries.total == retries.total @@ -386,12 +386,12 @@ def test_submit_image_query_with_low_request_timeout(gl: Groundlight, detector: Verifies that submit_image_query respects the request_timeout parameter and raises a ReadTimeoutError when timeout is exceeded. Verifies that request_timeout parameter can be a float or a tuple. """ - with pytest.raises(MaxRetryError): + with pytest.raises(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(MaxRetryError): + with pytest.raises(ReadTimeoutError): # Ensure a tuple can be passed. gl.submit_image_query(detector=detector, image=image, human_review="NEVER", request_timeout=(1e-8, 1e-8)) From 0bfd0ffd65945e22f456eaa6eb3d164bda20859b Mon Sep 17 00:00:00 2001 From: CoreyEWood Date: Wed, 24 Sep 2025 23:04:53 +0000 Subject: [PATCH 7/9] Test docstring fixes --- test/integration/test_groundlight.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/test_groundlight.py b/test/integration/test_groundlight.py index aa97ef68..7a2468cd 100644 --- a/test/integration/test_groundlight.py +++ b/test/integration/test_groundlight.py @@ -240,7 +240,7 @@ def test_get_detector(gl: Groundlight, 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 ReadTimeoutError when timeout is + Verifies that get_detector respects the request_timeout parameter and raises a MaxRetryError when timeout is exceeded. Verifies that request_timeout parameter can be a float or a tuple. """ with pytest.raises(MaxRetryError): @@ -383,8 +383,8 @@ 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): """ - Verifies that submit_image_query respects the request_timeout parameter and raises a ReadTimeoutError when timeout is - exceeded. Verifies that request_timeout parameter can be a float or a tuple. + Verifies that submit_image_query respects the request_timeout parameter and raises a ReadTimeoutError when timeout + is exceeded. Verifies that request_timeout parameter can be a float or a tuple. """ with pytest.raises(ReadTimeoutError): # Setting a very low request_timeout value should result in a timeout. From 56cc766e0f7b68694e967c2c94f9c1347f820026 Mon Sep 17 00:00:00 2001 From: CoreyEWood Date: Wed, 24 Sep 2025 23:12:25 +0000 Subject: [PATCH 8/9] fix test --- test/integration/test_groundlight.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/integration/test_groundlight.py b/test/integration/test_groundlight.py index 7a2468cd..1a1bc998 100644 --- a/test/integration/test_groundlight.py +++ b/test/integration/test_groundlight.py @@ -27,7 +27,7 @@ PaginatedDetectorList, PaginatedImageQueryList, ) -from urllib3.exceptions import MaxRetryError, ReadTimeoutError +from urllib3.exceptions import ConnectTimeoutError, MaxRetryError, ReadTimeoutError from urllib3.util.retry import Retry DEFAULT_CONFIDENCE_THRESHOLD = 0.9 @@ -386,14 +386,14 @@ def test_submit_image_query_with_low_request_timeout(gl: Groundlight, detector: Verifies that submit_image_query respects the request_timeout parameter and raises a ReadTimeoutError when timeout is exceeded. 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(ReadTimeoutError): + with pytest.raises((ConnectTimeoutError, ReadTimeoutError)): # Ensure a tuple can be passed. - gl.submit_image_query(detector=detector, image=image, human_review="NEVER", request_timeout=(1e-8, 1e-8)) + 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.") From 920f909323a0e8ad78dd895526d084e1d52f00dc Mon Sep 17 00:00:00 2001 From: CoreyEWood Date: Wed, 24 Sep 2025 23:17:08 +0000 Subject: [PATCH 9/9] fix test docstring --- test/integration/test_groundlight.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/test_groundlight.py b/test/integration/test_groundlight.py index 1a1bc998..902774eb 100644 --- a/test/integration/test_groundlight.py +++ b/test/integration/test_groundlight.py @@ -241,7 +241,7 @@ def test_get_detector(gl: Groundlight, 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 - exceeded. Verifies that request_timeout parameter can be a float or a tuple. + 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. @@ -383,8 +383,8 @@ 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): """ - Verifies that submit_image_query respects the request_timeout parameter and raises a ReadTimeoutError when timeout - is exceeded. Verifies that request_timeout parameter can be a float or a tuple. + 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((ConnectTimeoutError, ReadTimeoutError)): # Setting a very low request_timeout value should result in a timeout.