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,498 @@
1
+ import datetime
2
+ import glob
3
+ import os
4
+ import re
5
+ import xml.etree.ElementTree as ET
6
+ from pathlib import Path
7
+ from time import time_ns
8
+ from typing import Annotated, Callable, Dict, Generator, List, Tuple, Union
9
+
10
+ import click
11
+ from dateutil.parser import ParserError, parse
12
+ from junitparser import JUnitXml, TestCase, TestSuite # type: ignore # noqa: F401
13
+ from more_itertools import ichunked
14
+ from tabulate import tabulate
15
+
16
+ import smart_tests.args4p.converters as converters
17
+ import smart_tests.args4p.typer as typer
18
+ from smart_tests.utils.authentication import ensure_org_workspace
19
+ from smart_tests.utils.env_keys import REPORT_ERROR_KEY
20
+ from smart_tests.utils.session import get_session, parse_session
21
+ from smart_tests.utils.tracking import Tracking, TrackingClient
22
+
23
+ from ...app import Application
24
+ from ...args4p.command import Group
25
+ from ...args4p.exceptions import BadCmdLineException
26
+ from ...testpath import FilePathNormalizer, TestPathComponent, unparse_test_path
27
+ from ...utils.commands import Command
28
+ from ...utils.exceptions import InvalidJUnitXMLException, print_error_and_die
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.logger import Logger
32
+ from ...utils.smart_tests_client import SmartTestsClient
33
+ from .case_event import CaseEvent, CaseEventGenerator, CaseEventType, DataBuilder, TestPathBuilder
34
+
35
+ GROUP_NAME_RULE = re.compile("^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
36
+ RESERVED_GROUP_NAMES = ["group", "groups", "nogroup", "nogroups"]
37
+
38
+
39
+ def _validate_group(value):
40
+ if value is None:
41
+ return ""
42
+
43
+ if str(value).lower() in RESERVED_GROUP_NAMES:
44
+ raise BadCmdLineException(f"{value} is reserved name.")
45
+
46
+ if GROUP_NAME_RULE.match(value):
47
+ return value
48
+ else:
49
+ raise BadCmdLineException("group option supports only alphabet(a-z, A-Z), number(0-9), '-', and '_'")
50
+
51
+
52
+ ParseFunc = Callable[[str], CaseEventGenerator]
53
+
54
+
55
+ class RecordTests:
56
+ # The most generic form of parsing, where a path to a test report
57
+ # is turned into a generator by using CaseEvent.create()
58
+
59
+ # A common mechanism to build ParseFunc by building JUnit XML report in-memory (or build it the usual way
60
+ # and patch it to fix things up). This is handy as some libraries
61
+ # produce invalid / broken JUnit reports
62
+ JUnitXmlParseFunc = Callable[[str], ET.Element | ET.ElementTree]
63
+
64
+ path_builder: TestPathBuilder
65
+
66
+ parse_func: ParseFunc
67
+
68
+ build_name: str
69
+
70
+ test_session_id: int
71
+
72
+ # session is generated by `smart-tests record session` command
73
+ # the session format is `builds/<BUILD_NUMBER>/test_sessions/<TEST_SESSION_ID>`
74
+ session: str
75
+
76
+ # This function, if supplied, is used to build a test path
77
+ # that uniquely identifies a test case
78
+ metadata_builder: DataBuilder
79
+
80
+ # setter only property that sits on top of the parse_func property
81
+ def set_junitxml_parse_func(self, f: JUnitXmlParseFunc):
82
+ """
83
+ Parse XML report file with the JUnit report file, possibly with the custom parser function 'f'
84
+ that can be used to build JUnit ET.Element tree from scratch or do some patch up.
85
+
86
+ If f=None, the default parse code from JUnitParser module is used.
87
+ """
88
+
89
+ def parse(report: str) -> Generator[CaseEventType, None, None]:
90
+ # To understand JUnit XML format, https://llg.cubic.org/docs/junit/ is helpful
91
+ # TODO: robustness: what's the best way to deal with broken XML
92
+ # file, if any?
93
+ try:
94
+ xml = JUnitXml.fromfile(report, f)
95
+ except Exception as e:
96
+ # `JUnitXml.fromfile()` will raise `JUnitXmlError` and other lxml related errors
97
+ # if the file has wrong format.
98
+ # https://github.com/weiwei/junitparser/blob/master/junitparser/junitparser.py#L321
99
+ warn_and_exit_if_fail_fast_mode(
100
+ "Warning: error reading JUnitXml file {filename}: {error}".format(
101
+ filename=report, error=e))
102
+ return
103
+ if isinstance(xml, JUnitXml):
104
+ testsuites = [suite for suite in xml]
105
+ elif isinstance(xml, TestSuite):
106
+ testsuites = [xml]
107
+ else:
108
+ raise InvalidJUnitXMLException(filename=report)
109
+
110
+ try:
111
+ for suite in testsuites:
112
+ for case in suite:
113
+ yield CaseEvent.from_case_and_suite(self.path_builder, case, suite, report, self.metadata_builder)
114
+ except Exception as e:
115
+ warn_and_exit_if_fail_fast_mode(
116
+ "Warning: error parsing JUnitXml file {filename}: {error}".format(
117
+ filename=report, error=e))
118
+
119
+ self.parse_func = parse
120
+
121
+ junitxml_parse_func = property(None, set_junitxml_parse_func)
122
+
123
+ check_timestamp: bool
124
+
125
+ def __init__(
126
+ self,
127
+ app: Application,
128
+ session: Annotated[str, typer.Option(
129
+ "--session",
130
+ help="In the format builds/<build-name>/test_sessions/<test-session-id>",
131
+ required=True
132
+ )],
133
+ base_path: Annotated[Path | None, typer.Option(
134
+ "--base",
135
+ help="(Advanced) base directory to make test names portable",
136
+ type=converters.path(exists=True, file_okay=False, dir_okay=True, resolve_path=True),
137
+ )] = None,
138
+ post_chunk: Annotated[int, typer.Option(
139
+ "--post-chunk",
140
+ help="Post chunk"
141
+ )] = 1000,
142
+ no_base_path_inference: Annotated[bool, typer.Option(
143
+ "--no-base-path-inference",
144
+ help="Do not guess the base path to relativize the test file paths. By default, if the test file paths are "
145
+ "absolute file paths, it automatically guesses the repository root directory and relativize the paths. "
146
+ "With this option, the command doesn't do this guess work. If --base-path is specified, the absolute "
147
+ "file paths are relativized to the specified path irrelevant to this option. Use it if the guessed base "
148
+ "path is incorrect."
149
+ )] = False,
150
+ report_paths: Annotated[bool, typer.Option(
151
+ "--report-paths",
152
+ help="Instead of POSTing test results, just report test paths in the report file then quit. For diagnostics. "
153
+ "Use with --dry-run",
154
+ hidden=True
155
+ )] = False,
156
+ group: Annotated[str | None, typer.Option(
157
+ help="Grouping name for test results"
158
+ )] = "",
159
+ is_allow_test_before_build: Annotated[bool, typer.Option(
160
+ "--allow-test-before-build",
161
+ help="",
162
+ hidden=True
163
+ )] = False,
164
+ test_runner: Annotated[str | None, typer.Argument()] = None,
165
+ # TODO(Konboi): restore timestamp option
166
+ ):
167
+ self.logger = Logger()
168
+
169
+ self.org, self.workspace = ensure_org_workspace()
170
+
171
+ app.test_runner = test_runner
172
+ self.app = app
173
+
174
+ self.tracking_client = TrackingClient(Command.RECORD_TESTS, app=app)
175
+ self.client = SmartTestsClient(app=app, tracking_client=self.tracking_client)
176
+ set_fail_fast_mode(self.client.is_fail_fast_mode())
177
+
178
+ fail_fast_mode_validate(FailFastModeValidateParams(
179
+ command=Command.RECORD_TESTS,
180
+ session=session,
181
+ ))
182
+
183
+ self.post_chunk = post_chunk
184
+ self.report_paths = report_paths
185
+
186
+ # Validate group if provided and ensure it's never None
187
+ if group is None:
188
+ group = ""
189
+ elif group:
190
+ group = _validate_group(group)
191
+ self.group = group
192
+
193
+ self.file_path_normalizer = FilePathNormalizer(
194
+ str(base_path) if base_path else None,
195
+ no_base_path_inference=no_base_path_inference)
196
+
197
+ try:
198
+ test_session = get_session(session, self.client)
199
+ self.record_start_at = get_record_start_at(session, self.client)
200
+
201
+ test_session_id = test_session.id
202
+ build_name = test_session.build_name
203
+ except ValueError as e:
204
+ print_error_and_die(msg=str(e), event=Tracking.ErrorEvent.USER_ERROR, tracking_client=self.tracking_client)
205
+ except Exception as e:
206
+ if os.getenv(REPORT_ERROR_KEY):
207
+ raise e
208
+
209
+ self.tracking_client.send_error_event(
210
+ event_name=Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
211
+ stack_trace=str(e),
212
+ )
213
+ self.client.print_exception_and_recover(e)
214
+ # To prevent users from stopping the CI pipeline, the cli exits with a
215
+ # status code of 0, indicating that the program terminated successfully.
216
+ build_name, test_session_id = parse_session(session)
217
+ exit(0)
218
+
219
+ self.reports: List[str] = []
220
+ self.skipped_reports: List[str] = []
221
+ self.path_builder = CaseEvent.default_path_builder(self.file_path_normalizer)
222
+ self.junitxml_parse_func = None
223
+ self.check_timestamp = True
224
+ self.base_path = str(base_path) if base_path else None
225
+ self.dry_run = app.dry_run # TODO: remove
226
+ self.no_base_path_inference = no_base_path_inference
227
+ self.is_allow_test_before_build = is_allow_test_before_build
228
+ self.build_name = build_name
229
+ self.test_session_id = test_session_id
230
+ self.session = session
231
+ self.metadata_builder = CaseEvent.default_data_builder()
232
+
233
+ def make_file_path_component(self, filepath) -> TestPathComponent:
234
+ """Create a single TestPathComponent from the given file path"""
235
+ if self.base_path:
236
+ filepath = os.path.relpath(filepath, start=self.base_path)
237
+ return {"type": "file", "name": filepath}
238
+
239
+ def report(self, junit_report_file: str):
240
+ ctime = datetime.datetime.fromtimestamp(
241
+ os.path.getctime(junit_report_file))
242
+
243
+ if (
244
+ not self.is_allow_test_before_build # nlqa: W503
245
+ and self.check_timestamp # noqa: W503
246
+ and ctime.timestamp() < self.record_start_at.timestamp() # noqa: W503
247
+ ):
248
+ format = "%Y-%m-%d %H:%M:%S"
249
+ self.logger.warning(
250
+ f"skip: {junit_report_file} is too old to report. start_record_at:"
251
+ f"{self.record_start_at.strftime(format)} file_created_at: {ctime.strftime(format)}")
252
+ self.skipped_reports.append(junit_report_file)
253
+
254
+ return
255
+
256
+ self.reports.append(junit_report_file)
257
+
258
+ def scan(self, base: str, pattern: str):
259
+ """
260
+ Starting at the 'base' path, recursively add everything that matches the given GLOB pattern
261
+
262
+ scan('build/test-reports', '**/*.xml')
263
+ """
264
+ for t in glob.iglob(os.path.join(base, pattern), recursive=True):
265
+ self.report(t)
266
+
267
+ def run(self):
268
+ count = 0 # count number of test cases sent
269
+ is_observation = False
270
+
271
+ def testcases(reports: List[str]) -> Generator[CaseEventType, None, None]:
272
+ exceptions = []
273
+ for report in reports:
274
+ try:
275
+ for tc in self.parse_func(report):
276
+ # trim empty test path
277
+ if len(tc.get('testPath', [])) == 0:
278
+ continue
279
+
280
+ # Timestamp option has been removed
281
+
282
+ yield tc
283
+
284
+ except Exception as e:
285
+ exceptions.append(Exception(f"Failed to process a report file: {report}", e))
286
+
287
+ if len(exceptions) > 0:
288
+ # defer XML parsing exceptions so that we can send what we
289
+ # can send before we bail out
290
+ raise Exception(exceptions)
291
+
292
+ # generator that creates the payload incrementally
293
+ def payload(
294
+ cases: Generator[TestCase, None, None],
295
+ test_runner, group: str,
296
+ test_suite_name: str,
297
+ flavors: Dict[str, str]) -> Tuple[Dict[str, Union[str, List, dict, bool]], List[Exception]]:
298
+ nonlocal count
299
+ cs = []
300
+ exs = []
301
+
302
+ while True:
303
+ try:
304
+ cs.append(next(cases))
305
+ except StopIteration:
306
+ break
307
+ except Exception as ex:
308
+ exs.append(ex)
309
+
310
+ count += len(cs)
311
+ return {
312
+ "events": cs,
313
+ "testRunner": test_runner,
314
+ "group": group,
315
+ "metadata": get_env_values(self.client),
316
+ "noBuild": False, # deprecated to set no-build from the record tests command
317
+ # NOTE:
318
+ # testSuite and flavors are applied only when the no-build option is enabled
319
+ "testSuite": test_suite_name,
320
+ "flavors": flavors,
321
+ }, exs
322
+
323
+ def send(payload: Dict[str, Union[str, List]]) -> None:
324
+ res = self.client.request(
325
+ "post", f"{self.session}/events", payload=payload, compress=True)
326
+ res.raise_for_status()
327
+
328
+ nonlocal is_observation
329
+ is_observation = res.json().get("testSession", {}).get("isObservation", False)
330
+
331
+ def recorded_result() -> Tuple[int, int, int, float]:
332
+ test_count = 0
333
+ success_count = 0
334
+ fail_count = 0
335
+ duration = float(0)
336
+
337
+ for tc in testcases(self.reports):
338
+ test_count += 1
339
+ status = tc.get("status")
340
+ if status == 0:
341
+ fail_count += 1
342
+ elif status == 1:
343
+ success_count += 1
344
+ duration += float(tc.get("duration") or 0) # sec
345
+
346
+ return test_count, success_count, fail_count, duration / 60 # sec to min
347
+
348
+ try:
349
+ start = time_ns()
350
+ tc = testcases(self.reports)
351
+ end = time_ns()
352
+ self.tracking_client.send_event(
353
+ event_name=Tracking.Event.PERFORMANCE,
354
+ metadata={
355
+ "elapsedTime": end - start,
356
+ "measurementTarget": "testcases method(parsing report file)"
357
+ }
358
+ )
359
+
360
+ if self.report_paths:
361
+ # diagnostics mode to just report test paths
362
+ for t in tc:
363
+ print(unparse_test_path(t['testPath']))
364
+ return
365
+
366
+ start = time_ns()
367
+ exceptions = []
368
+ for chunk in ichunked(tc, self.post_chunk):
369
+ p, es = payload(
370
+ cases=chunk,
371
+ test_runner=self.app.test_runner,
372
+ group=self.group,
373
+ test_suite_name="", # test_suite option was removed
374
+ flavors={}, # flavor option was removed
375
+ )
376
+
377
+ send(p)
378
+ exceptions.extend(es)
379
+ end = time_ns()
380
+ self.tracking_client.send_event(
381
+ event_name=Tracking.Event.PERFORMANCE,
382
+ metadata={
383
+ "elapsedTime": end - start,
384
+ "measurementTarget": "events API"
385
+ }
386
+ )
387
+
388
+ if len(exceptions) > 0:
389
+ raise Exception(exceptions)
390
+
391
+ except Exception as e:
392
+ self.tracking_client.send_error_event(
393
+ event_name=Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
394
+ stack_trace=str(e),
395
+ )
396
+ self.client.print_exception_and_recover(e)
397
+ return
398
+
399
+ if count == 0:
400
+ if len(self.skipped_reports) != 0:
401
+ warn_and_exit_if_fail_fast_mode(
402
+ "{} test report(s) were skipped because they were created before this build was recorded.\n"
403
+ "Make sure to run your tests after you run `smart-tests record build`.\n"
404
+ "Otherwise, if these are really correct test reports, use the `--allow-test-before-build` option.".
405
+ format(len(self.skipped_reports)))
406
+ return
407
+ else:
408
+ warn_and_exit_if_fail_fast_mode(
409
+ "Looks like tests didn't run? If not, make sure the right files/directories were passed into `smart-tests record tests`") # noqa: E501
410
+ return
411
+
412
+ file_count = len(self.reports)
413
+ test_count, success_count, fail_count, duration = recorded_result()
414
+
415
+ click.echo(
416
+ f"Smart Tests recorded tests for build "
417
+ f"{self.build_name}(test session {self.test_session_id}) to "
418
+ f"workspace {self.org} / {self.workspace} from {file_count} files: ")
419
+
420
+ if is_observation:
421
+ click.echo("(This test session is under observation mode)")
422
+
423
+ click.echo("")
424
+
425
+ header = ["Files found", "Tests found", "Tests passed", "Tests failed", "Total duration (min)"]
426
+
427
+ rows = [[file_count, test_count, success_count, fail_count, duration]]
428
+ click.echo(tabulate(rows, header, tablefmt="github", floatfmt=".2f"))
429
+
430
+ if duration == 0:
431
+ click.secho("\nTotal test duration is 0."
432
+ "\nPlease check whether the test duration times in report files are correct.", fg="yellow")
433
+ click.echo(
434
+ f"\nVisit https://app.launchableinc.com/organizations/{self.org}/workspaces/"
435
+ f"{self.workspace}/test-sessions/{self.test_session_id} to view uploaded test results "
436
+ f"(or run `launchable inspect tests --test-session-id {self.test_session_id}`)")
437
+
438
+
439
+ tests = Group(name="tests", callback=RecordTests, help="Record test results")
440
+
441
+
442
+ # if we fail to determine the timestamp of the build, we err on the side of collecting more test reports
443
+ # than no test reports, so we use the 'epoch' timestamp
444
+ INVALID_TIMESTAMP = datetime.datetime.fromtimestamp(0)
445
+
446
+
447
+ def get_record_start_at(session: str, client: SmartTestsClient):
448
+ """
449
+ Determine the baseline timestamp to be used for up-to-date checks of report files.
450
+ Only files newer than this timestamp will be collected.
451
+
452
+ Based on the thinking that if a build doesn't exist tests couldn't have possibly run, we attempt
453
+ to use the timestamp of a build, with appropriate fallback.
454
+ """
455
+ build_name, _ = parse_session(session)
456
+
457
+ sub_path = f"builds/{build_name}"
458
+
459
+ res = client.request("get", sub_path)
460
+ if res.status_code != 200:
461
+ if res.status_code == 404:
462
+ msg = "Build {} was not found. " \
463
+ f"Make sure to run `smart-tests record build --name {build_name}` before `smart-tests record tests`"
464
+ else:
465
+ msg = f"Unable to determine the timestamp of the build {build_name}. HTTP response code was {res.status_code}"
466
+ click.secho(msg, fg='yellow', err=True)
467
+
468
+ # to avoid stop report command
469
+ return INVALID_TIMESTAMP
470
+
471
+ created_at = res.json()["createdAt"]
472
+ Logger().debug(f"Build {build_name} timestamp = {created_at}")
473
+ t = parse_launchable_timeformat(created_at)
474
+ return t
475
+
476
+
477
+ def parse_launchable_timeformat(t: str) -> datetime.datetime:
478
+ # e.g) "2021-04-01T09:35:47.934+00:00"
479
+ try:
480
+ return parse(t)
481
+ except ParserError:
482
+ return INVALID_TIMESTAMP
483
+
484
+
485
+ def get_env_values(client: SmartTestsClient) -> Dict[str, str]:
486
+ sub_path = "slack/notification/key/list"
487
+ res = client.request("get", sub_path=sub_path)
488
+
489
+ metadata: Dict[str, str] = {}
490
+ if res.status_code != 200:
491
+ return metadata
492
+
493
+ keys = res.json().get("keys", [])
494
+ for key in keys:
495
+ val = os.getenv(key, "")
496
+ metadata[key] = val
497
+
498
+ return metadata
@@ -0,0 +1,11 @@
1
+ from ... import args4p
2
+ from ...app import Application
3
+ from .test_sessions import test_sessions
4
+
5
+
6
+ @args4p.group()
7
+ def stats(app: Application):
8
+ return app
9
+
10
+
11
+ stats.add_command(test_sessions)
@@ -0,0 +1,45 @@
1
+ from typing import Annotated, Any, Dict, List
2
+
3
+ import click
4
+
5
+ import smart_tests.args4p.typer as typer
6
+
7
+ from ... import args4p
8
+ from ...app import Application
9
+ from ...utils.smart_tests_client import SmartTestsClient
10
+ from ...utils.typer_types import validate_key_value
11
+
12
+
13
+ @args4p.command(help="View test session statistics")
14
+ def test_sessions(
15
+ app: Application,
16
+ days: Annotated[int, typer.Option(
17
+ help="How many days of test sessions in the past to be stat"
18
+ )] = 7,
19
+ flavor: Annotated[List[str], typer.Option(
20
+ multiple=True,
21
+ help="flavors",
22
+ metavar="KEY=VALUE"
23
+ )] = [],
24
+ ):
25
+ # Parse flavors
26
+ parsed_flavors = [validate_key_value(f) for f in flavor]
27
+
28
+ params: Dict[str, Any] = {'days': days, 'flavor': []}
29
+ flavors = []
30
+ for f in parsed_flavors:
31
+ flavors.append('%s=%s' % (f[0], f[1]))
32
+
33
+ if flavors:
34
+ params['flavor'] = flavors
35
+ else:
36
+ params.pop('flavor', None)
37
+
38
+ client = SmartTestsClient(app=app)
39
+ try:
40
+ res = client.request('get', '/stats/test-sessions', params=params)
41
+ res.raise_for_status()
42
+ click.echo(res.text)
43
+
44
+ except Exception as e:
45
+ client.print_exception_and_recover(e, "Warning: the service failed to get stat.")