Skip to content

Commit d10a7c7

Browse files
authored
Merge pull request #1305 from cloudbees-oss/expose-subset-id
Add --subset-id-file option to capture subset ID without parsing stderr
2 parents 5ae5fef + 9f9f306 commit d10a7c7

2 files changed

Lines changed: 296 additions & 0 deletions

File tree

smart_tests/commands/subset.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ def __init__(
188188
"--print-input-snapshot-id",
189189
help="Print the input snapshot ID returned from the server instead of the subset results"
190190
)] = False,
191+
subset_id_file: Annotated[str | None, typer.Option(
192+
"--subset-id-file",
193+
help="Write the subset ID to a file",
194+
metavar="FILE",
195+
hidden=True
196+
)] = None,
191197
bin_target: Annotated[Fraction | None, typer.Option(
192198
"--bin",
193199
help="Split subset into bins, e.g. --bin 1/4",
@@ -284,6 +290,7 @@ def warn(msg: str):
284290
self.prioritized_tests_mapping_file = prioritized_tests_mapping_file
285291
self.input_snapshot_id = input_snapshot_id.value if input_snapshot_id else None
286292
self.print_input_snapshot_id = print_input_snapshot_id
293+
self.subset_id_file = subset_id_file
287294
self.bin_target = bin_target
288295
self.same_bin_files = list(same_bin_files)
289296
self.is_get_tests_from_guess = is_get_tests_from_guess
@@ -661,6 +668,18 @@ def _print_input_snapshot_id_value(self, subset_result: SubsetResult):
661668

662669
click.echo(subset_result.subset_id)
663670

671+
def _write_subset_id_to_file(self, subset_result: SubsetResult):
672+
if not subset_result.subset_id:
673+
print_error_and_die(
674+
"Subset request did not return a subset ID. Please re-run the command.",
675+
self.tracking_client,
676+
Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
677+
)
678+
679+
assert self.subset_id_file is not None # Early type guard
680+
with open(self.subset_id_file, 'w', encoding='utf-8') as f:
681+
f.write(str(subset_result.subset_id) + '\n')
682+
664683
def run(self):
665684
"""called after tests are scanned to compute the optimized order"""
666685

@@ -688,12 +707,17 @@ def run(self):
688707
warn_and_exit_if_fail_fast_mode("Error: no tests found matching the path.")
689708
if self.print_input_snapshot_id:
690709
self._print_input_snapshot_id_value(subset_result)
710+
if self.subset_id_file:
711+
self._write_subset_id_to_file(subset_result)
691712
return
692713

693714
if self.print_input_snapshot_id:
694715
self._print_input_snapshot_id_value(subset_result)
695716
return
696717

718+
if self.subset_id_file:
719+
self._write_subset_id_to_file(subset_result)
720+
697721
# TODO(Konboi): split subset isn't provided for smart-tests initial release
698722
# if split:
699723
# click.echo("subset/{}".format(subset_result.subset_id))

tests/commands/test_subset.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,3 +716,275 @@ def test_subset_with_same_bin_file(self):
716716
],
717717
]],
718718
)
719+
720+
@responses.activate
721+
@mock.patch.dict(os.environ, {"SMART_TESTS_TOKEN": CliTestCase.smart_tests_token})
722+
def test_subset_id_file_not_set(self):
723+
pipe = "test_1.py\ntest_2.py\n"
724+
mock_json_response = {
725+
"testPaths": [
726+
[{"type": "file", "name": "test_1.py"}],
727+
[{"type": "file", "name": "test_2.py"}],
728+
],
729+
"testRunner": "file",
730+
"rest": [],
731+
"subsettingId": self.subsetting_id,
732+
"summary": {
733+
"subset": {"duration": 10, "candidates": 2, "rate": 100},
734+
"rest": {"duration": 0, "candidates": 0, "rate": 0},
735+
},
736+
"isObservation": False,
737+
}
738+
responses.replace(
739+
responses.POST,
740+
f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/subset",
741+
json=mock_json_response,
742+
status=200,
743+
)
744+
745+
sentinel = tempfile.NamedTemporaryFile(delete=False)
746+
sentinel_path = sentinel.name
747+
sentinel.close()
748+
os.unlink(sentinel_path)
749+
750+
result = self.cli(
751+
"subset", "file",
752+
"--session", self.session,
753+
mix_stderr=False,
754+
input=pipe,
755+
)
756+
self.assert_success(result)
757+
self.assertEqual(result.stdout, "test_1.py\ntest_2.py\n")
758+
self.assertFalse(os.path.exists(sentinel_path))
759+
760+
@responses.activate
761+
@mock.patch.dict(os.environ, {"SMART_TESTS_TOKEN": CliTestCase.smart_tests_token})
762+
def test_subset_id_file_written(self):
763+
pipe = "test_1.py\ntest_2.py\n"
764+
mock_json_response = {
765+
"testPaths": [
766+
[{"type": "file", "name": "test_1.py"}],
767+
[{"type": "file", "name": "test_2.py"}],
768+
],
769+
"testRunner": "file",
770+
"rest": [],
771+
"subsettingId": self.subsetting_id,
772+
"summary": {
773+
"subset": {"duration": 10, "candidates": 2, "rate": 100},
774+
"rest": {"duration": 0, "candidates": 0, "rate": 0},
775+
},
776+
"isObservation": False,
777+
}
778+
responses.replace(
779+
responses.POST,
780+
f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/subset",
781+
json=mock_json_response,
782+
status=200,
783+
)
784+
785+
with tempfile.NamedTemporaryFile(delete=False) as id_file:
786+
id_file_path = id_file.name
787+
788+
try:
789+
result = self.cli(
790+
"subset", "file",
791+
"--session", self.session,
792+
"--subset-id-file", id_file_path,
793+
mix_stderr=False,
794+
input=pipe,
795+
)
796+
self.assert_success(result)
797+
self.assertEqual(result.stdout, "test_1.py\ntest_2.py\n")
798+
with open(id_file_path) as f:
799+
self.assertEqual(f.read(), f"{self.subsetting_id}\n")
800+
finally:
801+
os.unlink(id_file_path)
802+
803+
@responses.activate
804+
@mock.patch.dict(os.environ, {"SMART_TESTS_TOKEN": CliTestCase.smart_tests_token})
805+
def test_subset_id_file_with_target(self):
806+
pipe = "test_1.py\ntest_2.py\ntest_3.py\n"
807+
mock_json_response = {
808+
"testPaths": [
809+
[{"type": "file", "name": "test_1.py"}],
810+
],
811+
"testRunner": "file",
812+
"rest": [
813+
[{"type": "file", "name": "test_2.py"}],
814+
[{"type": "file", "name": "test_3.py"}],
815+
],
816+
"subsettingId": self.subsetting_id,
817+
"summary": {
818+
"subset": {"duration": 5, "candidates": 1, "rate": 33},
819+
"rest": {"duration": 10, "candidates": 2, "rate": 67},
820+
},
821+
"isObservation": False,
822+
}
823+
responses.replace(
824+
responses.POST,
825+
f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/subset",
826+
json=mock_json_response,
827+
status=200,
828+
)
829+
830+
with tempfile.NamedTemporaryFile(delete=False) as id_file:
831+
id_file_path = id_file.name
832+
833+
try:
834+
result = self.cli(
835+
"subset", "file",
836+
"--session", self.session,
837+
"--target", "30%",
838+
"--subset-id-file", id_file_path,
839+
mix_stderr=False,
840+
input=pipe,
841+
)
842+
self.assert_success(result)
843+
self.assertEqual(result.stdout, "test_1.py\n")
844+
with open(id_file_path) as f:
845+
self.assertEqual(f.read(), f"{self.subsetting_id}\n")
846+
finally:
847+
os.unlink(id_file_path)
848+
849+
@responses.activate
850+
@mock.patch.dict(os.environ, {"SMART_TESTS_TOKEN": CliTestCase.smart_tests_token})
851+
def test_subset_id_file_with_rest(self):
852+
pipe = "test_1.py\ntest_2.py\n"
853+
mock_json_response = {
854+
"testPaths": [
855+
[{"type": "file", "name": "test_1.py"}],
856+
],
857+
"testRunner": "file",
858+
"rest": [
859+
[{"type": "file", "name": "test_2.py"}],
860+
],
861+
"subsettingId": self.subsetting_id,
862+
"summary": {
863+
"subset": {"duration": 5, "candidates": 1, "rate": 50},
864+
"rest": {"duration": 5, "candidates": 1, "rate": 50},
865+
},
866+
"isObservation": False,
867+
}
868+
responses.replace(
869+
responses.POST,
870+
f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/subset",
871+
json=mock_json_response,
872+
status=200,
873+
)
874+
875+
with tempfile.NamedTemporaryFile(delete=False) as id_file, \
876+
tempfile.NamedTemporaryFile(delete=False) as rest_file:
877+
id_file_path = id_file.name
878+
rest_file_path = rest_file.name
879+
880+
try:
881+
result = self.cli(
882+
"subset", "file",
883+
"--session", self.session,
884+
"--rest", rest_file_path,
885+
"--subset-id-file", id_file_path,
886+
mix_stderr=False,
887+
input=pipe,
888+
)
889+
self.assert_success(result)
890+
self.assertEqual(result.stdout, "test_1.py\n")
891+
with open(id_file_path) as f:
892+
self.assertEqual(f.read(), f"{self.subsetting_id}\n")
893+
with open(rest_file_path) as f:
894+
self.assertIn("test_2.py", f.read())
895+
finally:
896+
os.unlink(id_file_path)
897+
os.unlink(rest_file_path)
898+
899+
@responses.activate
900+
@mock.patch.dict(os.environ, {"SMART_TESTS_TOKEN": CliTestCase.smart_tests_token})
901+
def test_subset_id_file_no_id_returned(self):
902+
pipe = "test_1.py\n"
903+
mock_json_response = {
904+
"testPaths": [
905+
[{"type": "file", "name": "test_1.py"}],
906+
],
907+
"testRunner": "file",
908+
"rest": [],
909+
"subsettingId": "",
910+
"summary": {
911+
"subset": {"duration": 5, "candidates": 1, "rate": 100},
912+
"rest": {"duration": 0, "candidates": 0, "rate": 0},
913+
},
914+
"isObservation": False,
915+
}
916+
responses.replace(
917+
responses.POST,
918+
f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/subset",
919+
json=mock_json_response,
920+
status=200,
921+
)
922+
923+
with tempfile.NamedTemporaryFile(delete=False) as id_file:
924+
id_file_path = id_file.name
925+
926+
try:
927+
result = self.cli(
928+
"subset", "file",
929+
"--session", self.session,
930+
"--subset-id-file", id_file_path,
931+
mix_stderr=False,
932+
input=pipe,
933+
)
934+
self.assert_exit_code(result, 1)
935+
self.assertIn("Subset request did not return a subset ID", result.stderr)
936+
finally:
937+
if os.path.exists(id_file_path):
938+
os.unlink(id_file_path)
939+
940+
@responses.activate
941+
@mock.patch.dict(os.environ, {"SMART_TESTS_TOKEN": CliTestCase.smart_tests_token})
942+
def test_subset_id_file_round_trip(self):
943+
pipe = "test_1.py\ntest_2.py\n"
944+
mock_json_response = {
945+
"testPaths": [
946+
[{"type": "file", "name": "test_1.py"}],
947+
[{"type": "file", "name": "test_2.py"}],
948+
],
949+
"testRunner": "file",
950+
"rest": [],
951+
"subsettingId": self.subsetting_id,
952+
"summary": {
953+
"subset": {"duration": 10, "candidates": 2, "rate": 100},
954+
"rest": {"duration": 0, "candidates": 0, "rate": 0},
955+
},
956+
"isObservation": False,
957+
}
958+
responses.replace(
959+
responses.POST,
960+
f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/subset",
961+
json=mock_json_response,
962+
status=200,
963+
)
964+
965+
with tempfile.NamedTemporaryFile(delete=False) as id_file:
966+
id_file_path = id_file.name
967+
968+
try:
969+
# Step 1: capture the subset ID into a file
970+
result = self.cli(
971+
"subset", "file",
972+
"--session", self.session,
973+
"--subset-id-file", id_file_path,
974+
mix_stderr=False,
975+
input=pipe,
976+
)
977+
self.assert_success(result)
978+
979+
# Step 2: feed the file back via --input-snapshot-id @file
980+
result2 = self.cli(
981+
"subset", "file",
982+
"--session", self.session,
983+
"--input-snapshot-id", f"@{id_file_path}",
984+
mix_stderr=False,
985+
)
986+
self.assert_success(result2)
987+
payload = self.decode_request_body(self.find_request('/subset', n=1).request.body)
988+
self.assertEqual(payload.get('subsettingId'), self.subsetting_id)
989+
finally:
990+
os.unlink(id_file_path)

0 commit comments

Comments
 (0)