Skip to content

Commit bc7d739

Browse files
timmarkhuffAuto-format Bot
andauthored
Fix flaky test caused by detector name collision in parallel CI jobs (#417)
### Problem Test detector names were generated with `f"Test {datetime.utcnow()}"`, which has second-level precision and is evaluated at module import time. When parallel CI matrix jobs imported the same module within the same second, they produced identical detector names. This caused `get_or_create_detector` calls to fail with a `unique_undeleted_name_per_set` constraint violation. Additionally, we have a lot of duplicated code for creating detector names, which could benefit from centralization. ### Fix Added a `detector_name` pytest fixture in `conftest.py` that returns a callable which appends a random UUID suffix to the timestamp, guaranteeing uniqueness across parallel runs. Replaced all inline detector name formatting across the test suite with calls to this fixture. --------- Co-authored-by: Auto-format Bot <autoformatbot@groundlight.ai>
1 parent 5353953 commit bc7d739

File tree

7 files changed

+97
-87
lines changed

7 files changed

+97
-87
lines changed

test/conftest.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
from datetime import datetime
2+
from typing import Callable
3+
from uuid import uuid4
24

35
import pytest
46
from groundlight import ExperimentalApi, Groundlight
57
from model import Detector, ImageQuery, ImageQueryTypeEnum, ResultTypeEnum
68

79

10+
def _generate_unique_detector_name(prefix: str = "Test") -> str:
11+
"""Generates a detector name with a timestamp and random suffix to ensure uniqueness."""
12+
return f"{prefix} {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')}_{uuid4().hex[:8]}"
13+
14+
15+
@pytest.fixture(name="detector_name")
16+
def fixture_detector_name() -> Callable[..., str]:
17+
"""Fixture that provides a callable to generate unique detector names."""
18+
return _generate_unique_detector_name
19+
20+
821
def pytest_configure(config): # pylint: disable=unused-argument
922
# Run environment check before tests
1023
gl = Groundlight()
@@ -25,20 +38,18 @@ def fixture_gl() -> Groundlight:
2538
@pytest.fixture(name="detector")
2639
def fixture_detector(gl: Groundlight) -> Detector:
2740
"""Creates a new Test detector."""
28-
name = f"Test {datetime.utcnow()}" # Need a unique name
2941
query = "Is there a dog?"
3042
pipeline_config = "never-review"
31-
return gl.create_detector(name=name, query=query, pipeline_config=pipeline_config)
43+
return gl.create_detector(name=_generate_unique_detector_name(), query=query, pipeline_config=pipeline_config)
3244

3345

3446
@pytest.fixture(name="count_detector")
3547
def fixture_count_detector(gl_experimental: ExperimentalApi) -> Detector:
3648
"""Creates a new Test detector."""
37-
name = f"Test {datetime.utcnow()}" # Need a unique name
3849
query = "How many dogs?"
3950
pipeline_config = "never-review-multi" # always predicts 0
4051
return gl_experimental.create_counting_detector(
41-
name=name, query=query, class_name="dog", pipeline_config=pipeline_config
52+
name=_generate_unique_detector_name(), query=query, class_name="dog", pipeline_config=pipeline_config
4253
)
4354

4455

test/integration/test_groundlight.py

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
import random
66
import string
77
import time
8-
from datetime import datetime
9-
from typing import Any, Dict, Optional, Union
8+
from typing import Any, Callable, Dict, Optional, Union
109

1110
import pytest
1211
from groundlight import Groundlight
@@ -96,8 +95,8 @@ def test_create_groundlight_with_retries():
9695
assert gl.api_client.configuration.retries.total == retries.total
9796

9897

99-
def test_create_detector(gl: Groundlight):
100-
name = f"Test {datetime.utcnow()}" # Need a unique name
98+
def test_create_detector(gl: Groundlight, detector_name: Callable):
99+
name = detector_name()
101100
query = "Is there a dog?"
102101
_detector = gl.create_detector(name=name, query=query)
103102
assert str(_detector)
@@ -107,29 +106,27 @@ def test_create_detector(gl: Groundlight):
107106
), "We expected the default confidence threshold to be used."
108107

109108
# Test creating dectors with other modes
110-
name = f"Test {datetime.utcnow()}" # Need a unique name
111-
count_detector = gl.create_detector(name=name, query=query, mode=ModeEnum.COUNT, class_names="dog")
109+
count_detector = gl.create_detector(name=detector_name(), query=query, mode=ModeEnum.COUNT, class_names="dog")
112110
assert str(count_detector)
113-
name = f"Test {datetime.utcnow()}" # Need a unique name
114111
multiclass_detector = gl.create_detector(
115-
name=name, query=query, mode=ModeEnum.MULTI_CLASS, class_names=["dog", "cat"]
112+
name=detector_name(), query=query, mode=ModeEnum.MULTI_CLASS, class_names=["dog", "cat"]
116113
)
117114
assert str(multiclass_detector)
118115

119116

120-
def test_create_detector_with_pipeline_config(gl: Groundlight):
117+
def test_create_detector_with_pipeline_config(gl: Groundlight, detector_name: Callable):
121118
# "never-review" is a special model that always returns the same result with 100% confidence.
122119
# It's useful for testing.
123-
name = f"Test never-review {datetime.utcnow()}" # Need a unique name
120+
name = detector_name("Test never-review")
124121
query = "Is there a dog (always-pass)?"
125122
pipeline_config = "never-review"
126123
_detector = gl.create_detector(name=name, query=query, pipeline_config=pipeline_config)
127124
assert str(_detector)
128125
assert isinstance(_detector, Detector)
129126

130127

131-
def test_create_detector_with_edge_pipeline_config(gl: Groundlight):
132-
name = f"Test edge-pipeline-config {datetime.utcnow()}"
128+
def test_create_detector_with_edge_pipeline_config(gl: Groundlight, detector_name: Callable):
129+
name = detector_name("Test edge-pipeline-config")
133130
query = "Is there a dog (edge-config)?"
134131
_detector = gl.create_detector(
135132
name=name,
@@ -141,10 +138,10 @@ def test_create_detector_with_edge_pipeline_config(gl: Groundlight):
141138
assert isinstance(_detector, Detector)
142139

143140

144-
def test_create_detector_with_confidence_threshold(gl: Groundlight):
141+
def test_create_detector_with_confidence_threshold(gl: Groundlight, detector_name: Callable):
145142
# "never-review" is a special model that always returns the same result with 100% confidence.
146143
# It's useful for testing.
147-
name = f"Test with confidence {datetime.utcnow()}" # Need a unique name
144+
name = detector_name("Test with confidence")
148145
query = "Is there a dog in the image?"
149146
pipeline_config = "never-review"
150147
confidence_threshold = 0.825
@@ -197,8 +194,8 @@ def test_create_detector_with_confidence_threshold(gl: Groundlight):
197194

198195

199196
@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint does not support passing detector metadata.")
200-
def test_create_detector_with_everything(gl: Groundlight):
201-
name = f"Test {datetime.utcnow()}" # Need a unique name
197+
def test_create_detector_with_everything(gl: Groundlight, detector_name: Callable):
198+
name = detector_name()
202199
query = "Is there a dog?"
203200
group_name = "Test group"
204201
confidence_threshold = 0.825
@@ -232,9 +229,9 @@ def test_list_detectors(gl: Groundlight):
232229
assert isinstance(detectors, PaginatedDetectorList)
233230

234231

235-
def test_get_or_create_detector(gl: Groundlight):
232+
def test_get_or_create_detector(gl: Groundlight, detector_name: Callable):
236233
# With a unique name, we should be creating a new detector.
237-
unique_name = f"Unique name {datetime.utcnow()}"
234+
unique_name = detector_name()
238235
query = "Is there a dog?"
239236
detector = gl.get_or_create_detector(name=unique_name, query=query)
240237
assert str(detector)
@@ -410,8 +407,8 @@ def test_submit_image_query_with_low_request_timeout(gl: Groundlight, detector:
410407

411408

412409
@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint does not support passing detector metadata.")
413-
def test_create_detector_with_metadata(gl: Groundlight):
414-
name = f"Test {datetime.utcnow()}" # Need a unique name
410+
def test_create_detector_with_metadata(gl: Groundlight, detector_name: Callable):
411+
name = detector_name()
415412
query = "Is there a dog?"
416413
metadata = generate_random_dict(target_size_bytes=200)
417414
detector = gl.create_detector(name=name, query=query, metadata=metadata)
@@ -422,8 +419,8 @@ def test_create_detector_with_metadata(gl: Groundlight):
422419

423420

424421
@pytest.mark.skip_for_edge_endpoint(reason="The edge-endpoint does not support passing detector metadata.")
425-
def test_get_or_create_detector_with_metadata(gl: Groundlight):
426-
unique_name = f"Unique name {datetime.utcnow()}"
422+
def test_get_or_create_detector_with_metadata(gl: Groundlight, detector_name: Callable):
423+
unique_name = detector_name()
427424
query = "Is there a dog?"
428425
metadata = generate_random_dict(target_size_bytes=200)
429426
detector = gl.get_or_create_detector(name=unique_name, query=query, metadata=metadata)
@@ -443,8 +440,8 @@ def test_get_or_create_detector_with_metadata(gl: Groundlight):
443440
[""],
444441
],
445442
)
446-
def test_create_detector_with_invalid_metadata(gl: Groundlight, metadata_list: Any):
447-
name = f"Test {datetime.utcnow()}" # Need a unique name
443+
def test_create_detector_with_invalid_metadata(gl: Groundlight, metadata_list: Any, detector_name: Callable):
444+
name = detector_name()
448445
query = "Is there a dog?"
449446

450447
for metadata in metadata_list:
@@ -627,9 +624,9 @@ def test_list_image_queries(gl: Groundlight):
627624
assert is_valid_display_result(image_query.result)
628625

629626

630-
def test_list_image_queries_with_filter(gl: Groundlight):
627+
def test_list_image_queries_with_filter(gl: Groundlight, detector_name: Callable):
631628
# We want a fresh detector so we know exactly what image queries are associated with it
632-
detector = gl.create_detector(name=f"Test {datetime.utcnow()}", query="Is there a dog?")
629+
detector = gl.create_detector(name=detector_name(), query="Is there a dog?")
633630
image_query_yes = gl.ask_async(detector=detector.id, image="test/assets/dog.jpeg", human_review="NEVER")
634631
image_query_no = gl.ask_async(detector=detector.id, image="test/assets/cat.jpeg", human_review="NEVER")
635632
iq_ids = [image_query_yes.id, image_query_no.id]
@@ -855,33 +852,33 @@ def test_submit_image_query_with_empty_inspection_id(gl: Groundlight, detector:
855852
)
856853

857854

858-
def test_binary_detector(gl: Groundlight):
855+
def test_binary_detector(gl: Groundlight, detector_name: Callable):
859856
"""
860857
verify that we can create and submit to a binary detector
861858
"""
862-
name = f"Test {datetime.utcnow()}"
859+
name = detector_name()
863860
created_detector = gl.create_binary_detector(name, "Is there a dog", confidence_threshold=0.0)
864861
assert created_detector is not None
865862
binary_iq = gl.submit_image_query(created_detector, "test/assets/dog.jpeg")
866863
assert binary_iq.result.label is not None
867864

868865

869-
def test_counting_detector(gl: Groundlight):
866+
def test_counting_detector(gl: Groundlight, detector_name: Callable):
870867
"""
871868
verify that we can create and submit to a counting detector
872869
"""
873-
name = f"Test {datetime.utcnow()}"
870+
name = detector_name()
874871
created_detector = gl.create_counting_detector(name, "How many dogs", "dog", confidence_threshold=0.0)
875872
assert created_detector is not None
876873
count_iq = gl.submit_image_query(created_detector, "test/assets/dog.jpeg")
877874
assert count_iq.result.count is not None
878875

879876

880-
def test_counting_detector_async(gl: Groundlight):
877+
def test_counting_detector_async(gl: Groundlight, detector_name: Callable):
881878
"""
882879
verify that we can create and submit to a counting detector
883880
"""
884-
name = f"Test {datetime.utcnow()}"
881+
name = detector_name()
885882
created_detector = gl.create_counting_detector(name, "How many dogs", "dog", confidence_threshold=0.0)
886883
assert created_detector is not None
887884
async_iq = gl.ask_async(created_detector, "test/assets/dog.jpeg")
@@ -896,11 +893,11 @@ def test_counting_detector_async(gl: Groundlight):
896893
assert _image_query.result is not None
897894

898895

899-
def test_multiclass_detector(gl: Groundlight):
896+
def test_multiclass_detector(gl: Groundlight, detector_name: Callable):
900897
"""
901898
verify that we can create and submit to a multi-class detector
902899
"""
903-
name = f"Test {datetime.utcnow()}"
900+
name = detector_name()
904901
class_names = ["Golden Retriever", "Labrador Retriever", "Poodle"]
905902
created_detector = gl.create_multiclass_detector(
906903
name, "What kind of dog is this?", class_names=class_names, confidence_threshold=0.0
@@ -911,12 +908,12 @@ def test_multiclass_detector(gl: Groundlight):
911908
assert mc_iq.result.label in class_names
912909

913910

914-
def test_delete_detector(gl: Groundlight):
911+
def test_delete_detector(gl: Groundlight, detector_name: Callable):
915912
"""
916913
Test deleting a detector by both ID and object, and verify proper error handling.
917914
"""
918915
# Create a detector to delete
919-
name = f"Test delete detector {datetime.utcnow()}"
916+
name = detector_name("Test delete detector")
920917
query = "Is there a dog to delete?"
921918
pipeline_config = "never-review"
922919
detector = gl.create_detector(name=name, query=query, pipeline_config=pipeline_config)
@@ -929,7 +926,7 @@ def test_delete_detector(gl: Groundlight):
929926
gl.get_detector(detector.id)
930927

931928
# Create another detector to test deletion by ID string and that an attached image query is deleted
932-
name2 = f"Test delete detector 2 {datetime.utcnow()}"
929+
name2 = detector_name("Test delete detector 2")
933930
detector2 = gl.create_detector(name=name2, query=query, pipeline_config=pipeline_config)
934931
gl.submit_image_query(detector2, "test/assets/dog.jpeg")
935932

@@ -952,14 +949,14 @@ def test_delete_detector(gl: Groundlight):
952949
gl.delete_detector(fake_detector_id) # type: ignore
953950

954951

955-
def test_create_detector_with_invalid_priming_group_id(gl: Groundlight):
952+
def test_create_detector_with_invalid_priming_group_id(gl: Groundlight, detector_name: Callable):
956953
"""
957954
Test that creating a detector with a non-existent priming_group_id returns an appropriate error.
958955
959956
Note: PrimingGroup IDs are provided by Groundlight representatives. If you would like to
960957
use a priming_group_id, please reach out to your Groundlight representative.
961958
"""
962-
name = f"Test invalid priming {datetime.utcnow()}"
959+
name = detector_name("Test invalid priming")
963960
query = "Is there a dog?"
964961
pipeline_config = "never-review"
965962
priming_group_id = "prgrp_nonexistent12345678901234567890"

test/integration/test_groundlight_expensive.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# pylint: disable=wildcard-import,unused-wildcard-import,redefined-outer-name,import-outside-toplevel
88
import random
99
import time
10-
from datetime import datetime
10+
from typing import Callable
1111

1212
import pytest
1313
from groundlight import Groundlight
@@ -30,8 +30,8 @@ def fixture_gl() -> Groundlight:
3030

3131

3232
@pytest.mark.skip(reason="This test requires a human labeler who does not need to be in the testing loop")
33-
def test_human_label(gl: Groundlight):
34-
detector = gl.create_detector(name=f"Test {datetime.utcnow()}", query="Is there a dog?")
33+
def test_human_label(gl: Groundlight, detector_name: Callable):
34+
detector = gl.create_detector(name=detector_name(), query="Is there a dog?")
3535
img_query = gl.submit_image_query(
3636
detector=detector.id, image="test/assets/dog.jpeg", wait=60, human_review="ALWAYS"
3737
)
@@ -52,7 +52,7 @@ def test_human_label(gl: Groundlight):
5252

5353
@pytest.mark.skip(reason="This test can block development depending on the state of the service")
5454
@pytest.mark.skipif(MISSING_PIL, reason="Needs pillow") # type: ignore
55-
def test_detector_improvement(gl: Groundlight):
55+
def test_detector_improvement(gl: Groundlight, detector_name: Callable):
5656
# test that we get confidence improvement after sending images in
5757
# Pass two of each type of image in
5858
import time
@@ -61,7 +61,7 @@ def test_detector_improvement(gl: Groundlight):
6161

6262
random.seed(2741)
6363

64-
name = f"Test test_detector_improvement {datetime.utcnow()}" # Need a unique name
64+
name = detector_name("Test test_detector_improvement")
6565
query = "Is there a dog?"
6666
detector = gl.create_detector(name=name, query=query)
6767

@@ -122,11 +122,11 @@ def submit_noisy_image(image, label=None):
122122
@pytest.mark.skip(
123123
reason="We don't yet have an SLA level to test ask_confident against, and the test is flakey as a result"
124124
)
125-
def test_ask_method_quality(gl: Groundlight, detector: Detector):
125+
def test_ask_method_quality(gl: Groundlight, detector: Detector, detector_name: Callable):
126126
# asks for some level of quality on how fast ask_ml is and that we will get a confident result from ask_confident
127127
fast_always_yes_iq = gl.ask_ml(detector=detector.id, image="test/assets/dog.jpeg", wait=0)
128128
assert iq_is_answered(fast_always_yes_iq)
129-
name = f"Test {datetime.utcnow()}" # Need a unique name
129+
name = detector_name()
130130
query = "Is there a dog?"
131131
detector = gl.create_detector(name=name, query=query, confidence_threshold=0.8)
132132
fast_iq = gl.ask_ml(detector=detector.id, image="test/assets/dog.jpeg", wait=0)

test/unit/test_cli.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import re
33
import subprocess
4-
from datetime import datetime
4+
from typing import Callable
55
from unittest.mock import patch
66

77

@@ -23,9 +23,9 @@ def test_list_detector():
2323
assert completed_process.returncode == 0
2424

2525

26-
def test_detector_and_image_queries():
26+
def test_detector_and_image_queries(detector_name: Callable):
2727
# test creating a detector
28-
test_detector_name = f"testdetector {datetime.utcnow()}"
28+
test_detector_name = detector_name("testdetector")
2929
completed_process = subprocess.run(
3030
[
3131
"groundlight",
@@ -41,7 +41,9 @@ def test_detector_and_image_queries():
4141
check=False,
4242
)
4343
assert completed_process.returncode == 0
44-
det_id_on_create = re.search("id='([^']+)'", completed_process.stdout).group(1)
44+
match = re.search("id='([^']+)'", completed_process.stdout)
45+
assert match is not None
46+
det_id_on_create = match.group(1)
4547
# The output of the create-detector command looks something like:
4648
# id='det_abc123'
4749
# type=<DetectorTypeEnum.detector: 'detector'>
@@ -59,7 +61,9 @@ def test_detector_and_image_queries():
5961
check=False,
6062
)
6163
assert completed_process.returncode == 0
62-
det_id_on_get = re.search("id='([^']+)'", completed_process.stdout).group(1)
64+
match = re.search("id='([^']+)'", completed_process.stdout)
65+
assert match is not None
66+
det_id_on_get = match.group(1)
6367
assert det_id_on_create == det_id_on_get
6468
completed_process = subprocess.run(
6569
["groundlight", "get-detector", det_id_on_create],

0 commit comments

Comments
 (0)