smart-tests-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. smart_tests/__init__.py +0 -0
  2. smart_tests/__main__.py +60 -0
  3. smart_tests/app.py +67 -0
  4. smart_tests/args4p/README.md +102 -0
  5. smart_tests/args4p/__init__.py +13 -0
  6. smart_tests/args4p/argument.py +45 -0
  7. smart_tests/args4p/command.py +593 -0
  8. smart_tests/args4p/converters/__init__.py +75 -0
  9. smart_tests/args4p/decorators.py +98 -0
  10. smart_tests/args4p/exceptions.py +12 -0
  11. smart_tests/args4p/option.py +85 -0
  12. smart_tests/args4p/parameter.py +84 -0
  13. smart_tests/args4p/typer/__init__.py +42 -0
  14. smart_tests/commands/__init__.py +0 -0
  15. smart_tests/commands/compare/__init__.py +11 -0
  16. smart_tests/commands/compare/subsets.py +58 -0
  17. smart_tests/commands/detect_flakes.py +105 -0
  18. smart_tests/commands/inspect/__init__.py +13 -0
  19. smart_tests/commands/inspect/model.py +52 -0
  20. smart_tests/commands/inspect/subset.py +138 -0
  21. smart_tests/commands/record/__init__.py +19 -0
  22. smart_tests/commands/record/attachment.py +38 -0
  23. smart_tests/commands/record/build.py +356 -0
  24. smart_tests/commands/record/case_event.py +190 -0
  25. smart_tests/commands/record/commit.py +157 -0
  26. smart_tests/commands/record/session.py +120 -0
  27. smart_tests/commands/record/tests.py +498 -0
  28. smart_tests/commands/stats/__init__.py +11 -0
  29. smart_tests/commands/stats/test_sessions.py +45 -0
  30. smart_tests/commands/subset.py +567 -0
  31. smart_tests/commands/test_path_writer.py +51 -0
  32. smart_tests/commands/verify.py +153 -0
  33. smart_tests/jar/exe_deploy.jar +0 -0
  34. smart_tests/plugins/__init__.py +0 -0
  35. smart_tests/test_runners/__init__.py +0 -0
  36. smart_tests/test_runners/adb.py +24 -0
  37. smart_tests/test_runners/ant.py +35 -0
  38. smart_tests/test_runners/bazel.py +103 -0
  39. smart_tests/test_runners/behave.py +62 -0
  40. smart_tests/test_runners/codeceptjs.py +33 -0
  41. smart_tests/test_runners/ctest.py +164 -0
  42. smart_tests/test_runners/cts.py +189 -0
  43. smart_tests/test_runners/cucumber.py +451 -0
  44. smart_tests/test_runners/cypress.py +46 -0
  45. smart_tests/test_runners/dotnet.py +106 -0
  46. smart_tests/test_runners/file.py +20 -0
  47. smart_tests/test_runners/flutter.py +251 -0
  48. smart_tests/test_runners/go_test.py +99 -0
  49. smart_tests/test_runners/googletest.py +34 -0
  50. smart_tests/test_runners/gradle.py +96 -0
  51. smart_tests/test_runners/jest.py +52 -0
  52. smart_tests/test_runners/maven.py +149 -0
  53. smart_tests/test_runners/minitest.py +40 -0
  54. smart_tests/test_runners/nunit.py +190 -0
  55. smart_tests/test_runners/playwright.py +252 -0
  56. smart_tests/test_runners/prove.py +74 -0
  57. smart_tests/test_runners/pytest.py +358 -0
  58. smart_tests/test_runners/raw.py +238 -0
  59. smart_tests/test_runners/robot.py +125 -0
  60. smart_tests/test_runners/rspec.py +5 -0
  61. smart_tests/test_runners/smart_tests.py +235 -0
  62. smart_tests/test_runners/vitest.py +49 -0
  63. smart_tests/test_runners/xctest.py +79 -0
  64. smart_tests/testpath.py +154 -0
  65. smart_tests/utils/__init__.py +0 -0
  66. smart_tests/utils/authentication.py +78 -0
  67. smart_tests/utils/ci_provider.py +7 -0
  68. smart_tests/utils/commands.py +14 -0
  69. smart_tests/utils/commit_ingester.py +59 -0
  70. smart_tests/utils/common_tz.py +12 -0
  71. smart_tests/utils/edit_distance.py +11 -0
  72. smart_tests/utils/env_keys.py +19 -0
  73. smart_tests/utils/exceptions.py +34 -0
  74. smart_tests/utils/fail_fast_mode.py +99 -0
  75. smart_tests/utils/file_name_pattern.py +4 -0
  76. smart_tests/utils/git_log_parser.py +53 -0
  77. smart_tests/utils/glob.py +44 -0
  78. smart_tests/utils/gzipgen.py +46 -0
  79. smart_tests/utils/http_client.py +169 -0
  80. smart_tests/utils/java.py +61 -0
  81. smart_tests/utils/link.py +149 -0
  82. smart_tests/utils/logger.py +53 -0
  83. smart_tests/utils/no_build.py +2 -0
  84. smart_tests/utils/sax.py +119 -0
  85. smart_tests/utils/session.py +73 -0
  86. smart_tests/utils/smart_tests_client.py +134 -0
  87. smart_tests/utils/subprocess.py +12 -0
  88. smart_tests/utils/tracking.py +95 -0
  89. smart_tests/utils/typer_types.py +241 -0
  90. smart_tests/version.py +7 -0
  91. smart_tests_cli-2.0.0.dist-info/METADATA +168 -0
  92. smart_tests_cli-2.0.0.dist-info/RECORD +96 -0
  93. smart_tests_cli-2.0.0.dist-info/WHEEL +5 -0
  94. smart_tests_cli-2.0.0.dist-info/entry_points.txt +2 -0
  95. smart_tests_cli-2.0.0.dist-info/licenses/LICENSE.txt +202 -0
  96. smart_tests_cli-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,567 @@
1
+ import glob
2
+ import json
3
+ import os
4
+ import pathlib
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from enum import Enum
9
+ from io import TextIOWrapper
10
+ from multiprocessing import Process
11
+ from os.path import join
12
+ from typing import Annotated, Any, Callable, Dict, Iterable, List
13
+
14
+ import click
15
+ from tabulate import tabulate
16
+
17
+ import smart_tests.args4p.typer as typer
18
+ from smart_tests.utils.authentication import get_org_workspace
19
+ from smart_tests.utils.commands import Command
20
+ from smart_tests.utils.exceptions import print_error_and_die
21
+ from smart_tests.utils.session import get_session, parse_session
22
+ from smart_tests.utils.tracking import Tracking, TrackingClient
23
+
24
+ from ..app import Application
25
+ from ..args4p.command import Group
26
+ from ..args4p.converters import fileText, floatType, intType
27
+ from ..testpath import FilePathNormalizer, TestPath
28
+ from ..utils.env_keys import REPORT_ERROR_KEY
29
+ from ..utils.fail_fast_mode import (FailFastModeValidateParams, fail_fast_mode_validate,
30
+ set_fail_fast_mode, warn_and_exit_if_fail_fast_mode)
31
+ from ..utils.smart_tests_client import SmartTestsClient
32
+ from ..utils.typer_types import Duration, Percentage, parse_duration, parse_percentage
33
+ from .test_path_writer import TestPathWriter
34
+
35
+
36
+ class SubsetUseCase(str, Enum):
37
+ ONE_COMMIT = "one-commit"
38
+ FEATURE_BRANCH = "feature-branch"
39
+ RECURRING = "recurring"
40
+
41
+
42
+ class SubsetResult:
43
+ def __init__(
44
+ self,
45
+ subset=None,
46
+ rest=None,
47
+ subset_id: str = "",
48
+ summary=None,
49
+ is_brainless: bool = False,
50
+ is_observation: bool = False):
51
+ self.subset = subset or []
52
+ self.rest = rest or []
53
+ self.subset_id = subset_id
54
+ self.summary = summary or {}
55
+ self.is_brainless = is_brainless
56
+ self.is_observation = is_observation
57
+
58
+ @classmethod
59
+ def from_response(cls, response: Dict[str, Any]) -> 'SubsetResult':
60
+ return cls(
61
+ subset=response.get("testPaths", []),
62
+ rest=response.get("rest", []),
63
+ subset_id=response.get("subsettingId", ""),
64
+ summary=response.get("summary", {}),
65
+ is_brainless=response.get("isBrainless", False),
66
+ is_observation=response.get("isObservation", False)
67
+ )
68
+
69
+ @classmethod
70
+ def from_test_paths(cls, test_paths: List[TestPath]) -> 'SubsetResult':
71
+ return cls(
72
+ subset=test_paths,
73
+ rest=[],
74
+ subset_id='',
75
+ summary={},
76
+ is_brainless=False,
77
+ is_observation=False
78
+ )
79
+
80
+
81
+ # Where we take TestPath, we also accept a path name as a string.
82
+ TestPathLike = str | TestPath
83
+
84
+
85
+ class Subset(TestPathWriter):
86
+ # test_paths: List[TestPath] # doesn't work with Python 3.5
87
+ # is_get_tests_from_previous_sessions: bool
88
+
89
+ input_given = False # set to True when an attempt was made to add to self.test_paths
90
+
91
+ # output_handler: Callable[[
92
+ # List[TestPathLike], List[TestPathLike]], None]
93
+
94
+ # (Kohsuke) function that takes (subset,rest) and output the rest part, I think.
95
+ # I'm actually not entirely sure what this pluggability does
96
+ exclusion_output_handler: Callable[[List[TestPath], List[TestPath]], None]
97
+
98
+ def __init__(
99
+ self,
100
+ app: Application,
101
+ session: Annotated[str, typer.Option(
102
+ "--session",
103
+ help="In the format builds/<build-name>/test_sessions/<test-session-id>",
104
+ metavar="SESSION",
105
+ required=True
106
+ )],
107
+ target: Annotated[Percentage | None, typer.Option(
108
+ type=parse_percentage,
109
+ help="subsetting target from 0% to 100%"
110
+ )] = None,
111
+ time: Annotated[Duration | None, typer.Option(
112
+ type=parse_duration,
113
+ help="subsetting by absolute time, in seconds e.g) 300, 5m"
114
+ )] = None,
115
+ confidence: Annotated[Percentage | None, typer.Option(
116
+ type=parse_percentage,
117
+ help="subsetting by confidence from 0% to 100%"
118
+ )] = None,
119
+ goal_spec: Annotated[str | None, typer.Option(
120
+ help="subsetting by programmatic goal definition"
121
+ )] = None,
122
+ base_path: Annotated[str | None, typer.Option(
123
+ '--base',
124
+ help="(Advanced) base directory to make test names portable",
125
+ metavar="DIR"
126
+ )] = None,
127
+ rest: Annotated[str | None, typer.Option(
128
+ help="Output the subset remainder to a file, e.g. `--rest=remainder.txt`"
129
+ )] = None,
130
+ # TODO(Konboi): omit from the smart-tests command initial release
131
+ # split: Annotated[bool, typer.Option(
132
+ # help="split"
133
+ # )] = False,
134
+ no_base_path_inference: Annotated[bool, typer.Option(
135
+ "--no-base-path-inference",
136
+ help="Do not guess the base path to relativize the test file paths. "
137
+ "By default, if the test file paths are absolute file paths, it automatically "
138
+ "guesses the repository root directory and relativize the paths. With this "
139
+ "option, the command doesn't do this guess work. "
140
+ "If --base is specified, the absolute file paths are relativized to the "
141
+ "specified path irrelevant to this option. Use it if the guessed base path is incorrect."
142
+ )] = False,
143
+ ignore_new_tests: Annotated[bool, typer.Option(
144
+ "--ignore-new-tests",
145
+ help="Ignore tests that were added recently. "
146
+ "NOTICE: this option will ignore tests that you added just now as well"
147
+ )] = False,
148
+ is_get_tests_from_previous_sessions: Annotated[bool, typer.Option(
149
+ "--get-tests-from-previous-sessions",
150
+ help="get subset list from previous full tests"
151
+ )] = False,
152
+ is_output_exclusion_rules: Annotated[bool, typer.Option(
153
+ "--output-exclusion-rules",
154
+ help="outputs the exclude test list. Switch the subset and rest."
155
+ )] = False,
156
+ is_non_blocking: Annotated[bool, typer.Option(
157
+ "--non-blocking",
158
+ help="Do not wait for subset requests in observation mode.",
159
+ hidden=True
160
+ )] = False,
161
+ ignore_flaky_tests_above: Annotated[float | None, typer.Option(
162
+ help="Ignore flaky tests above the value set by this option. You can confirm flaky scores in WebApp",
163
+ type=floatType(min=0.0, max=1.0)
164
+ )] = None,
165
+ prioritize_tests_failed_within_hours: Annotated[int | None, typer.Option(
166
+ help="Prioritize tests that failed within the specified hours; maximum 720 hours (= 24 hours * 30 days)",
167
+ type=intType(min=0, max=24 * 30)
168
+ )] = None,
169
+ prioritized_tests_mapping_file: Annotated[TextIOWrapper | None, typer.Option(
170
+ "--prioritized-tests-mapping",
171
+ help="Prioritize tests based on test mapping file",
172
+ type=fileText(mode="r")
173
+ )] = None,
174
+ is_get_tests_from_guess: Annotated[bool, typer.Option(
175
+ "--get-tests-from-guess",
176
+ help="Get subset list from guessed tests"
177
+ )] = False,
178
+ use_case: Annotated[SubsetUseCase | None, typer.Option(
179
+ "--use-case",
180
+ hidden=True
181
+ )] = None,
182
+ test_runner: Annotated[str | None, typer.Argument()] = None,
183
+ ):
184
+ super().__init__(app)
185
+
186
+ app.test_runner = test_runner
187
+ self.tracking_client = TrackingClient(Command.SUBSET, app=app)
188
+ self.client = SmartTestsClient(app=app, tracking_client=self.tracking_client)
189
+
190
+ set_fail_fast_mode(self.client.is_fail_fast_mode())
191
+ fail_fast_mode_validate(FailFastModeValidateParams(command=Command.SUBSET, session=session))
192
+
193
+ def warn(msg: str):
194
+ click.secho("Warning: " + msg, fg="yellow", err=True)
195
+ self.tracking_client.send_error_event(
196
+ event_name=Tracking.ErrorEvent.WARNING_ERROR,
197
+ stack_trace=msg
198
+ )
199
+
200
+ # Note(Konboi): when get_session throws exception, is_observation won't be defined
201
+ # To avoid that, we define is_observation here (out of try block)
202
+ is_observation = False
203
+ try:
204
+ test_session = get_session(session, self.client)
205
+ self.build_name = test_session.build_name
206
+ self.session_id = test_session.id
207
+ is_observation = test_session.observation_mode
208
+ except ValueError as e:
209
+ print_error_and_die(msg=str(e), tracking_client=self.tracking_client, event=Tracking.ErrorEvent.USER_ERROR)
210
+ except Exception as e:
211
+ if os.getenv(REPORT_ERROR_KEY):
212
+ raise e
213
+ else:
214
+ # not to block pipeline, parse session and use it
215
+ self.client.print_exception_and_recover(e, "Warning: failed to check test session")
216
+ self.build_name, self.session_id = parse_session(session)
217
+
218
+ if is_get_tests_from_guess and is_get_tests_from_previous_sessions:
219
+ print_error_and_die(
220
+ "--get-tests-from-guess (list up tests from git ls-files and subset from there) and --get-tests-from-previous-sessions (list up tests from the recent runs and subset from there) are mutually exclusive. Which one do you want to use?", # noqa E501
221
+ self.tracking_client,
222
+ Tracking.ErrorEvent.USER_ERROR
223
+ )
224
+
225
+ if is_observation and is_output_exclusion_rules:
226
+ warn("--observation and --output-exclusion-rules are set. No output will be generated.")
227
+
228
+ if prioritize_tests_failed_within_hours is not None and prioritize_tests_failed_within_hours > 0:
229
+ if ignore_new_tests or (ignore_flaky_tests_above is not None and ignore_flaky_tests_above > 0):
230
+ print_error_and_die(
231
+ "Cannot use --ignore-new-tests or --ignore-flaky-tests-above options with --prioritize-tests-failed-within-hours", # noqa E501
232
+ self.tracking_client,
233
+ Tracking.ErrorEvent.INTERNAL_CLI_ERROR)
234
+
235
+ if is_non_blocking and not is_observation:
236
+ print_error_and_die(
237
+ "You have to specify --observation option to use non-blocking mode",
238
+ self.tracking_client,
239
+ Tracking.ErrorEvent.INTERNAL_CLI_ERROR)
240
+
241
+ self.target = target
242
+ self.time = time
243
+ self.confidence = confidence
244
+ self.goal_spec = goal_spec
245
+ self.base_path = base_path
246
+ self.base_path_explicitly_set = (base_path is not None)
247
+ self.rest = rest
248
+ self.ignore_new_tests = ignore_new_tests
249
+ self.is_get_tests_from_previous_sessions = is_get_tests_from_previous_sessions
250
+ self.is_output_exclusion_rules = is_output_exclusion_rules
251
+ self.is_non_blocking = is_non_blocking
252
+ self.ignore_flaky_tests_above = ignore_flaky_tests_above
253
+ self.prioritize_tests_failed_within_hours = prioritize_tests_failed_within_hours
254
+ self.prioritized_tests_mapping_file = prioritized_tests_mapping_file
255
+ self.is_get_tests_from_guess = is_get_tests_from_guess
256
+ self.use_case = use_case
257
+
258
+ self.file_path_normalizer = FilePathNormalizer(base_path, no_base_path_inference=no_base_path_inference)
259
+
260
+ self.test_paths: list[list[dict[str, str]]] = []
261
+ self.output_handler = self._default_output_handler
262
+ self.exclusion_output_handler = self._default_exclusion_output_handler
263
+
264
+ def _default_output_handler(self, output: list[TestPath], rests: list[TestPath]):
265
+ if self.rest:
266
+ self.write_file(self.rest, rests)
267
+
268
+ if output:
269
+ self.print(output)
270
+
271
+ def _default_exclusion_output_handler(self, subset: list[TestPath], rest: list[TestPath]):
272
+ self.output_handler(rest, subset)
273
+
274
+ def test_path(self, path: TestPathLike):
275
+ """register one test"""
276
+
277
+ def rel_base_path(path):
278
+ if isinstance(path, str):
279
+ return pathlib.Path(self.file_path_normalizer.relativize(path)).as_posix()
280
+ else:
281
+ return path
282
+
283
+ self.input_given = True
284
+ if isinstance(path, str) and any(s in path for s in ('*', "?")):
285
+ for i in glob.iglob(path, recursive=True):
286
+ if os.path.isfile(i):
287
+ self.test_paths.append(self.to_test_path(rel_base_path(i)))
288
+ else:
289
+ self.test_paths.append(self.to_test_path(rel_base_path(path)))
290
+
291
+ def stdin(self) -> Iterable[str]:
292
+ """
293
+ Returns sys.stdin, but after ensuring that it's connected to something reasonable.
294
+
295
+ This prevents a typical problem where users think CLI is hanging because
296
+ they didn't feed anything from stdin
297
+
298
+ HACK(Kohsuke): When is_get_tests_from_previous_sessions was added, that flag should have
299
+ selected the code path that doesn't use stdin. But instead, for some reasons the change
300
+ was made to make stdin() return empty list. Until we fix that, this function is returning
301
+ Iterable[str], so that we can return [] as "empty stdin".
302
+ """
303
+
304
+ # To avoid the cli continue to wait from stdin
305
+ if self.is_get_tests_from_previous_sessions or self.is_get_tests_from_guess:
306
+ return []
307
+
308
+ if sys.stdin.isatty():
309
+ warn_and_exit_if_fail_fast_mode(
310
+ "Warning: this command reads from stdin but it doesn't appear to be connected to anything. "
311
+ "Did you forget to pipe from another command?"
312
+ )
313
+ return sys.stdin
314
+
315
+ @staticmethod
316
+ def to_test_path(x: TestPathLike) -> TestPath:
317
+ """Convert input to a TestPath"""
318
+ if isinstance(x, str):
319
+ # default representation for a file
320
+ return [{'type': 'file', 'name': x}]
321
+ else:
322
+ return x
323
+
324
+ def scan(self, base: str, pattern: str,
325
+ path_builder: Callable[[str], TestPathLike | None] | None = None):
326
+ """
327
+ Starting at the 'base' path, recursively add everything that matches the given GLOB pattern
328
+
329
+ scan('src/test/java', '**/*.java')
330
+
331
+ 'path_builder' is a function used to map file name into a custom test path.
332
+ It takes a single string argument that represents the portion matched to the glob pattern,
333
+ and its return value controls what happens to that file:
334
+ - skip a file by returning a False-like object
335
+ - if a str is returned, that's interpreted as a path name and
336
+ converted to the default test path representation. Typically, `os.path.join(base,file_name)
337
+ - if a TestPath is returned, that's added as is
338
+ """
339
+
340
+ self.input_given = True
341
+
342
+ if path_builder is None:
343
+ # default implementation of path_builder creates a file name relative to `source` so as not
344
+ # to be affected by the path
345
+ def default_path_builder(file_name):
346
+ return pathlib.Path(self.file_path_normalizer.relativize(join(base, file_name))).as_posix()
347
+
348
+ path_builder = default_path_builder
349
+
350
+ for b in glob.iglob(base):
351
+ for t in glob.iglob(join(b, pattern), recursive=True):
352
+ path = path_builder(os.path.relpath(t, b))
353
+ if path:
354
+ self.test_paths.append(self.to_test_path(path))
355
+
356
+ def get_payload(self) -> dict[str, Any]:
357
+ payload: dict[str, Any] = {
358
+ "testPaths": self.test_paths,
359
+ "testRunner": self.app.test_runner,
360
+ "session": {
361
+ # expecting just the last component, not the whole path
362
+ "id": os.path.basename(str(self.session_id))
363
+ },
364
+ "ignoreNewTests": self.ignore_new_tests,
365
+ "getTestsFromPreviousSessions": self.is_get_tests_from_previous_sessions,
366
+ "getTestsFromGuess": self.is_get_tests_from_guess,
367
+ }
368
+
369
+ if self.target is not None:
370
+ payload["goal"] = {
371
+ "type": "subset-by-percentage",
372
+ "percentage": float(self.target),
373
+ }
374
+ elif self.time is not None:
375
+ payload["goal"] = {
376
+ "type": "subset-by-absolute-time",
377
+ "duration": float(self.time),
378
+ }
379
+ elif self.confidence is not None:
380
+ payload["goal"] = {
381
+ "type": "subset-by-confidence",
382
+ "percentage": float(self.confidence)
383
+ }
384
+ elif self.goal_spec is not None:
385
+ payload["goal"] = {
386
+ "type": "subset-by-goal-spec",
387
+ "goal": self.goal_spec
388
+ }
389
+ else:
390
+ payload['useServerSideOptimizationTarget'] = True
391
+
392
+ if self.ignore_flaky_tests_above:
393
+ payload["dropFlakinessThreshold"] = self.ignore_flaky_tests_above
394
+
395
+ if self.prioritize_tests_failed_within_hours:
396
+ payload["hoursToPrioritizeFailedTest"] = self.prioritize_tests_failed_within_hours
397
+
398
+ if self.prioritized_tests_mapping_file:
399
+ payload['prioritizedTestsMapping'] = json.load(self.prioritized_tests_mapping_file)
400
+
401
+ if self.use_case:
402
+ payload['changesUnderTest'] = self.use_case.value
403
+
404
+ return payload
405
+
406
+ def _collect_potential_test_files(self):
407
+ LOOSE_TEST_FILE_PATTERN = r'(\.(test|spec)\.|_test\.|Test\.|Spec\.|test/|tests/|__tests__/|src/test/)'
408
+ EXCLUDE_PATTERN = r'\.(xml|json|txt|yml|yaml|md)$'
409
+
410
+ try:
411
+ git_managed_files = subprocess.run(['git', 'ls-files'], stdout=subprocess.PIPE,
412
+ universal_newlines=True, check=True).stdout.strip().split('\n')
413
+ except subprocess.CalledProcessError as e:
414
+ warn_and_exit_if_fail_fast_mode(f"git ls-files failed (exit code={e.returncode})")
415
+ return
416
+ except OSError as e:
417
+ warn_and_exit_if_fail_fast_mode(f"git ls-files failed: {e}")
418
+ return
419
+
420
+ found = False
421
+ for f in git_managed_files:
422
+ if re.search(LOOSE_TEST_FILE_PATTERN, f) and not re.search(EXCLUDE_PATTERN, f):
423
+ self.test_paths.append(self.to_test_path(f))
424
+ found = True
425
+
426
+ if not found:
427
+ warn_and_exit_if_fail_fast_mode("Nothing that looks like a test file in the current git repository.")
428
+
429
+ def request_subset(self) -> SubsetResult:
430
+ # temporarily extend the timeout because subset API response has become slow
431
+ # TODO: remove this line when API response return response
432
+ # within 300 sec
433
+ timeout = (5, 300)
434
+ payload = self.get_payload()
435
+
436
+ if self.is_non_blocking:
437
+ # Create a new process for requesting a subset.
438
+ process = Process(target=subset_request, args=(self.client, timeout, payload))
439
+ process.start()
440
+ click.echo("The subset was requested in non-blocking mode.", err=True)
441
+ self.output_handler(self.test_paths, [])
442
+ # With non-blocking mode, we don't need to wait for the response
443
+ sys.exit(0)
444
+
445
+ try:
446
+ res = subset_request(client=self.client, timeout=timeout, payload=payload)
447
+ # The status code 422 is returned when validation error of the test mapping file occurs.
448
+ if res.status_code == 422:
449
+ print_error_and_die("Error: {}".format(res.reason), self.tracking_client, Tracking.ErrorEvent.USER_ERROR)
450
+
451
+ res.raise_for_status()
452
+
453
+ return SubsetResult.from_response(res.json())
454
+ except Exception as e:
455
+ self.tracking_client.send_error_event(
456
+ event_name=Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
457
+ stack_trace=str(e),
458
+ )
459
+ self.client.print_exception_and_recover(
460
+ e, "Warning: the service failed to subset. Falling back to running all tests")
461
+ return SubsetResult.from_test_paths(self.test_paths)
462
+
463
+ def run(self):
464
+ """called after tests are scanned to compute the optimized order"""
465
+
466
+ if self.is_get_tests_from_guess:
467
+ self._collect_potential_test_files()
468
+
469
+ if not self.is_get_tests_from_previous_sessions and len(self.test_paths) == 0:
470
+ if self.input_given:
471
+ print_error_and_die("ERROR: Given arguments did not match any tests. They appear to be incorrect/non-existent.", tracking_client, Tracking.ErrorEvent.USER_ERROR) # noqa E501
472
+ else:
473
+ print_error_and_die(
474
+ "ERROR: Expecting tests to be given, but none provided. See https://help.launchableinc.com/features/predictive-test-selection/requesting-and-running-a-subset-of-tests/ and provide ones, or use the `--get-tests-from-previous-sessions` option", # noqa E501
475
+ self.tracking_client,
476
+ Tracking.ErrorEvent.USER_ERROR)
477
+
478
+ # When Error occurs, return the test name as it is passed.
479
+ if not self.session_id:
480
+ # Session ID in --session is missing. It might be caused by
481
+ # Launchable API errors.
482
+ subset_result = SubsetResult.from_test_paths(self.test_paths)
483
+ else:
484
+ subset_result = self.request_subset()
485
+
486
+ if len(subset_result.subset) == 0:
487
+ warn_and_exit_if_fail_fast_mode("Error: no tests found matching the path.")
488
+ return
489
+
490
+ # TODO(Konboi): split subset isn't provided for smart-tests initial release
491
+ # if split:
492
+ # click.echo("subset/{}".format(subset_result.subset_id))
493
+ output_subset, output_rests = subset_result.subset, subset_result.rest
494
+
495
+ if subset_result.is_observation:
496
+ output_subset = output_subset + output_rests
497
+ output_rests = []
498
+
499
+ if self.is_output_exclusion_rules:
500
+ self.exclusion_output_handler(output_subset, output_rests)
501
+ else:
502
+ self.output_handler(output_subset, output_rests)
503
+
504
+ # When Launchable returns an error, the cli skips showing summary
505
+ # report
506
+ original_subset = subset_result.subset
507
+ original_rest = subset_result.rest
508
+ summary = subset_result.summary
509
+ if "subset" not in summary.keys() or "rest" not in summary.keys():
510
+ return
511
+
512
+ org, workspace = get_org_workspace()
513
+
514
+ header = ["", "Candidates",
515
+ "Estimated duration (%)", "Estimated duration (min)"]
516
+ rows = [
517
+ [
518
+ "Subset",
519
+ len(original_subset),
520
+ summary["subset"].get("rate", 0.0),
521
+ summary["subset"].get("duration", 0.0),
522
+ ],
523
+ [
524
+ "Remainder",
525
+ len(original_rest),
526
+ summary["rest"].get("rate", 0.0),
527
+ summary["rest"].get("duration", 0.0),
528
+ ],
529
+ [],
530
+ [
531
+ "Total",
532
+ len(original_subset) + len(original_rest),
533
+ summary["subset"].get("rate", 0.0) + summary["rest"].get("rate", 0.0),
534
+ summary["subset"].get("duration", 0.0) + summary["rest"].get("duration", 0.0),
535
+ ],
536
+ ]
537
+
538
+ if subset_result.is_brainless:
539
+ click.echo(
540
+ "Your model is currently in training", err=True)
541
+
542
+ click.echo(
543
+ "Smart Tests created subset {} for build {} (test session {}) in workspace {}/{}".format(
544
+ subset_result.subset_id,
545
+ self.build_name,
546
+ self.session_id,
547
+ org, workspace,
548
+ ), err=True,
549
+ )
550
+ if subset_result.is_observation:
551
+ click.echo(
552
+ "(This test session is under observation mode)",
553
+ err=True)
554
+
555
+ click.echo("", err=True)
556
+ click.echo(tabulate(rows, header, tablefmt="github", floatfmt=".2f"), err=True)
557
+
558
+ click.echo(
559
+ "\nRun `smart-tests inspect subset --subset-id {}` to view full subset details".format(subset_result.subset_id),
560
+ err=True)
561
+
562
+
563
+ subset = Group(callback=Subset, help="Subsetting tests")
564
+
565
+
566
+ def subset_request(client: SmartTestsClient, timeout: tuple[int, int], payload: dict[str, Any]):
567
+ return client.request("post", "subset", timeout=timeout, payload=payload, compress=True)
@@ -0,0 +1,51 @@
1
+ from os.path import join
2
+ from typing import Callable, Dict, List
3
+
4
+ import click
5
+
6
+ from ..app import Application
7
+ from ..testpath import TestPath
8
+
9
+
10
+ class TestPathWriter(object):
11
+ base_path: str | None = None
12
+ base_path_explicitly_set: bool = False # Track if base_path was explicitly provided
13
+
14
+ # string to write between different test paths
15
+ separator: str = "\n"
16
+
17
+ # pluggable logic to convert TestPath to a printable form
18
+ formatter: Callable[[TestPath], str]
19
+
20
+ def __init__(self, app: Application):
21
+ self.formatter = self.default_formatter
22
+ self._same_bin_formatter: Callable[[str], Dict[str, str]] | None = None
23
+ self.separator = "\n"
24
+ self.app = app
25
+
26
+ def default_formatter(self, x: TestPath):
27
+ """default formatter that's in line with to_test_path(str)"""
28
+ file_name = x[0]['name']
29
+ # Only prepend base_path if it was explicitly set via --base option
30
+ # Auto-inferred base paths should not affect output formatting
31
+ if self.base_path and self.base_path_explicitly_set:
32
+ # default behavior consistent with default_path_builder's relative
33
+ # path handling
34
+ file_name = join(str(self.base_path), file_name)
35
+ return file_name
36
+
37
+ def write_file(self, file: str, test_paths: List[TestPath]):
38
+ open(file, "w+", encoding="utf-8").write(
39
+ self.separator.join(self.formatter(t) for t in test_paths))
40
+
41
+ def print(self, test_paths: List[TestPath]):
42
+ click.echo(self.separator.join(self.formatter(t)
43
+ for t in test_paths))
44
+
45
+ @property
46
+ def same_bin_formatter(self) -> Callable[[str], Dict[str, str]] | None:
47
+ return self._same_bin_formatter
48
+
49
+ @same_bin_formatter.setter
50
+ def same_bin_formatter(self, v: Callable[[str], Dict[str, str]]):
51
+ self._same_bin_formatter = v