Skip to content

Commit 010d18b

Browse files
authored
Merge pull request #1328 from cloudbees-oss/backport-from-main-20260609
Add --subset-id-file option to capture subset ID without parsing stderr
2 parents 0a35c00 + 38ad02e commit 010d18b

2 files changed

Lines changed: 254 additions & 0 deletions

File tree

launchable/commands/subset.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,14 @@
223223
type=click.Choice(["one-commit", "feature-branch", "recurring"]),
224224
hidden=True, # control PTS v2 test selection behavior. Non-committed, so hidden for now.
225225
)
226+
@click.option(
227+
"--subset-id-file",
228+
"subset_id_file",
229+
help="Write the subset ID to a file",
230+
metavar="FILE",
231+
type=str,
232+
hidden=True,
233+
)
226234
@click.pass_context
227235
def subset(
228236
context: click.core.Context,
@@ -253,6 +261,7 @@ def subset(
253261
is_get_tests_from_guess: bool = False,
254262
use_case: Optional[str] = None,
255263
similarity: Optional[float] = None,
264+
subset_id_file: Optional[str] = None,
256265
):
257266
app = context.obj
258267
tracking_client = TrackingClient(Command.SUBSET, app=app)
@@ -392,6 +401,7 @@ def __init__(self, app: Application):
392401
self.is_get_tests_from_guess = is_get_tests_from_guess
393402
self.is_output_exclusion_rules = is_output_exclusion_rules
394403
self.is_get_tests_from_guess = is_get_tests_from_guess
404+
self.subset_id_file = subset_id_file
395405
super(Optimize, self).__init__(app=app)
396406

397407
def _default_output_handler(self, output: List[TestPath], rests: List[TestPath]):
@@ -566,6 +576,17 @@ def _collect_potential_test_files(self):
566576
if not found:
567577
warn_and_exit_if_fail_fast_mode("Nothing that looks like a test file in the current git repository.")
568578

579+
def _write_subset_id_to_file(self, subset_result: SubsetResult):
580+
if not subset_result.subset_id:
581+
print_error_and_die(
582+
"Subset request did not return a subset ID. Please re-run the command.",
583+
Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
584+
)
585+
586+
assert self.subset_id_file is not None # Early type guard
587+
with open(self.subset_id_file, 'w', encoding='utf-8') as f:
588+
f.write(str(subset_result.subset_id) + '\n')
589+
569590
def request_subset(self) -> SubsetResult:
570591
test_runner = context.invoked_subcommand
571592
# temporarily extend the timeout because subset API response has become slow
@@ -648,6 +669,9 @@ def run(self):
648669
else:
649670
self.output_handler(output_subset, output_rests)
650671

672+
if self.subset_id_file:
673+
self._write_subset_id_to_file(subset_result)
674+
651675
# When Launchable returns an error, the cli skips showing summary
652676
# report
653677
original_subset = subset_result.subset

tests/commands/test_subset.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,3 +687,233 @@ def test_subset_with_get_tests_from_guess(self):
687687
"""
688688
payload = json.loads(gzip.decompress(responses.calls[1].request.body).decode())
689689
self.assertIn([{"type": "file", "name": "tests/commands/test_subset.py"}], payload.get("testPaths", []))
690+
691+
@responses.activate
692+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
693+
def test_subset_id_file_not_set(self):
694+
pipe = "test_1.py\ntest_2.py\n"
695+
mock_json_response = {
696+
"testPaths": [
697+
[{"type": "file", "name": "test_1.py"}],
698+
[{"type": "file", "name": "test_2.py"}],
699+
],
700+
"testRunner": "file",
701+
"rest": [],
702+
"subsettingId": self.subsetting_id,
703+
"summary": {
704+
"subset": {"duration": 10, "candidates": 2, "rate": 100},
705+
"rest": {"duration": 0, "candidates": 0, "rate": 0},
706+
},
707+
"isObservation": False,
708+
}
709+
responses.replace(
710+
responses.POST,
711+
"{}/intake/organizations/{}/workspaces/{}/subset".format(
712+
get_base_url(), self.organization, self.workspace),
713+
json=mock_json_response,
714+
status=200,
715+
)
716+
717+
sentinel = tempfile.NamedTemporaryFile(delete=False)
718+
sentinel_path = sentinel.name
719+
sentinel.close()
720+
os.unlink(sentinel_path)
721+
722+
result = self.cli(
723+
"subset",
724+
"--session", self.session,
725+
"file",
726+
mix_stderr=False,
727+
input=pipe,
728+
)
729+
self.assert_success(result)
730+
self.assertEqual(result.stdout, "test_1.py\ntest_2.py\n")
731+
self.assertFalse(os.path.exists(sentinel_path))
732+
733+
@responses.activate
734+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
735+
def test_subset_id_file_written(self):
736+
pipe = "test_1.py\ntest_2.py\n"
737+
mock_json_response = {
738+
"testPaths": [
739+
[{"type": "file", "name": "test_1.py"}],
740+
[{"type": "file", "name": "test_2.py"}],
741+
],
742+
"testRunner": "file",
743+
"rest": [],
744+
"subsettingId": self.subsetting_id,
745+
"summary": {
746+
"subset": {"duration": 10, "candidates": 2, "rate": 100},
747+
"rest": {"duration": 0, "candidates": 0, "rate": 0},
748+
},
749+
"isObservation": False,
750+
}
751+
responses.replace(
752+
responses.POST,
753+
"{}/intake/organizations/{}/workspaces/{}/subset".format(
754+
get_base_url(), self.organization, self.workspace),
755+
json=mock_json_response,
756+
status=200,
757+
)
758+
759+
with tempfile.NamedTemporaryFile(delete=False) as id_file:
760+
id_file_path = id_file.name
761+
762+
try:
763+
result = self.cli(
764+
"subset",
765+
"--session", self.session,
766+
"--subset-id-file", id_file_path,
767+
"file",
768+
mix_stderr=False,
769+
input=pipe,
770+
)
771+
self.assert_success(result)
772+
self.assertEqual(result.stdout, "test_1.py\ntest_2.py\n")
773+
with open(id_file_path) as f:
774+
self.assertEqual(f.read(), "{}\n".format(self.subsetting_id))
775+
finally:
776+
os.unlink(id_file_path)
777+
778+
@responses.activate
779+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
780+
def test_subset_id_file_with_target(self):
781+
pipe = "test_1.py\ntest_2.py\ntest_3.py\n"
782+
mock_json_response = {
783+
"testPaths": [
784+
[{"type": "file", "name": "test_1.py"}],
785+
],
786+
"testRunner": "file",
787+
"rest": [
788+
[{"type": "file", "name": "test_2.py"}],
789+
[{"type": "file", "name": "test_3.py"}],
790+
],
791+
"subsettingId": self.subsetting_id,
792+
"summary": {
793+
"subset": {"duration": 5, "candidates": 1, "rate": 33},
794+
"rest": {"duration": 10, "candidates": 2, "rate": 67},
795+
},
796+
"isObservation": False,
797+
}
798+
responses.replace(
799+
responses.POST,
800+
"{}/intake/organizations/{}/workspaces/{}/subset".format(
801+
get_base_url(), self.organization, self.workspace),
802+
json=mock_json_response,
803+
status=200,
804+
)
805+
806+
with tempfile.NamedTemporaryFile(delete=False) as id_file:
807+
id_file_path = id_file.name
808+
809+
try:
810+
result = self.cli(
811+
"subset",
812+
"--session", self.session,
813+
"--target", "30%",
814+
"--subset-id-file", id_file_path,
815+
"file",
816+
mix_stderr=False,
817+
input=pipe,
818+
)
819+
self.assert_success(result)
820+
self.assertEqual(result.stdout, "test_1.py\n")
821+
with open(id_file_path) as f:
822+
self.assertEqual(f.read(), "{}\n".format(self.subsetting_id))
823+
finally:
824+
os.unlink(id_file_path)
825+
826+
@responses.activate
827+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
828+
def test_subset_id_file_with_rest(self):
829+
pipe = "test_1.py\ntest_2.py\n"
830+
mock_json_response = {
831+
"testPaths": [
832+
[{"type": "file", "name": "test_1.py"}],
833+
],
834+
"testRunner": "file",
835+
"rest": [
836+
[{"type": "file", "name": "test_2.py"}],
837+
],
838+
"subsettingId": self.subsetting_id,
839+
"summary": {
840+
"subset": {"duration": 5, "candidates": 1, "rate": 50},
841+
"rest": {"duration": 5, "candidates": 1, "rate": 50},
842+
},
843+
"isObservation": False,
844+
}
845+
responses.replace(
846+
responses.POST,
847+
"{}/intake/organizations/{}/workspaces/{}/subset".format(
848+
get_base_url(), self.organization, self.workspace),
849+
json=mock_json_response,
850+
status=200,
851+
)
852+
853+
with tempfile.NamedTemporaryFile(delete=False) as id_file, \
854+
tempfile.NamedTemporaryFile(delete=False) as rest_file:
855+
id_file_path = id_file.name
856+
rest_file_path = rest_file.name
857+
858+
try:
859+
result = self.cli(
860+
"subset",
861+
"--session", self.session,
862+
"--rest", rest_file_path,
863+
"--subset-id-file", id_file_path,
864+
"file",
865+
mix_stderr=False,
866+
input=pipe,
867+
)
868+
self.assert_success(result)
869+
self.assertEqual(result.stdout, "test_1.py\n")
870+
with open(id_file_path) as f:
871+
self.assertEqual(f.read(), "{}\n".format(self.subsetting_id))
872+
with open(rest_file_path) as f:
873+
self.assertIn("test_2.py", f.read())
874+
finally:
875+
os.unlink(id_file_path)
876+
os.unlink(rest_file_path)
877+
878+
@responses.activate
879+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
880+
def test_subset_id_file_no_id_returned(self):
881+
pipe = "test_1.py\n"
882+
mock_json_response = {
883+
"testPaths": [
884+
[{"type": "file", "name": "test_1.py"}],
885+
],
886+
"testRunner": "file",
887+
"rest": [],
888+
"subsettingId": "",
889+
"summary": {
890+
"subset": {"duration": 5, "candidates": 1, "rate": 100},
891+
"rest": {"duration": 0, "candidates": 0, "rate": 0},
892+
},
893+
"isObservation": False,
894+
}
895+
responses.replace(
896+
responses.POST,
897+
"{}/intake/organizations/{}/workspaces/{}/subset".format(
898+
get_base_url(), self.organization, self.workspace),
899+
json=mock_json_response,
900+
status=200,
901+
)
902+
903+
with tempfile.NamedTemporaryFile(delete=False) as id_file:
904+
id_file_path = id_file.name
905+
906+
try:
907+
result = self.cli(
908+
"subset",
909+
"--session", self.session,
910+
"--subset-id-file", id_file_path,
911+
"file",
912+
mix_stderr=False,
913+
input=pipe,
914+
)
915+
self.assert_exit_code(result, 1)
916+
self.assertIn("Subset request did not return a subset ID", result.stderr)
917+
finally:
918+
if os.path.exists(id_file_path):
919+
os.unlink(id_file_path)

0 commit comments

Comments
 (0)