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,61 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ from typing import Callable
6
+
7
+ from junitparser import TestCase, TestSuite
8
+
9
+ from smart_tests.testpath import TestPath
10
+
11
+ TestPathBuilder = Callable[[TestCase, TestSuite, str], TestPath]
12
+
13
+
14
+ def get_java_command():
15
+ if shutil.which("java"):
16
+ return "java"
17
+
18
+ if os.access(os.path.expandvars("$JAVA_HOME/bin/java"), os.X_OK):
19
+ return os.path.expandvars("$JAVA_HOME/bin/java")
20
+
21
+ return None
22
+
23
+
24
+ def cygpath(p):
25
+ # When running in Cygwin ported Python (as opposed to Windows native Python), the paths we deal with are in
26
+ # the cygwin format. But when we invoke Windows native Java (and there's no Cygwin ported Java), those parameters
27
+ # need to be in the Windows path format.
28
+ #
29
+ # In Cygwin aware world, it is always the responsibility of the Cygwin process calling Windows native process to
30
+ # do this conversion, so here we are.
31
+ #
32
+ # Cygwin ported Python is not to be confused with Windows native Python running in Windows with cygwin.
33
+ # So tests like CYGWIN env var, "uname" are incorrect.
34
+ if sys.platform == 'cygwin':
35
+ p = subprocess.check_output(['cygpath', '-w', p]).decode().strip()
36
+ return p
37
+
38
+
39
+ def junit5_nested_class_path_builder(
40
+ default_path_builder: TestPathBuilder) -> TestPathBuilder:
41
+ """
42
+ Creates a path builder function that handles JUnit 5 nested class names.
43
+
44
+ With @Nested tests in JUnit 5, test class names have inner class names
45
+ like com.launchableinc.rocket_car.NestedTest$InnerClass.
46
+ It causes a problem in subsetting because Smart Tests CLI can't detect inner classes in subsetting.
47
+ So, we need to ignore the inner class names. The inner class name is separated by $.
48
+ Note: Launchable allows $ in test paths. But we decided to remove it in this case
49
+ because $ in the class name is not a common case.
50
+
51
+ Args:
52
+ default_path_builder: The original path builder function to wrap
53
+
54
+ Returns:
55
+ A function that wraps the default path builder and handles nested class names
56
+ """
57
+ def path_builder(case: TestCase, suite: TestSuite, report_file: str) -> TestPath:
58
+ test_path = default_path_builder(case, suite, report_file)
59
+ return [{**item, "name": item["name"].split("$")[0]} if item["type"] == "class" else item for item in test_path]
60
+
61
+ return path_builder
@@ -0,0 +1,149 @@
1
+ import re
2
+ from enum import Enum
3
+ from typing import Dict, List, Mapping, Sequence
4
+
5
+ from smart_tests.args4p.exceptions import BadCmdLineException
6
+ from smart_tests.utils.typer_types import KeyValue
7
+
8
+ JENKINS_URL_KEY = 'JENKINS_URL'
9
+ JENKINS_BUILD_URL_KEY = 'BUILD_URL'
10
+ JENKINS_BUILD_DISPLAY_NAME_KEY = 'BUILD_DISPLAY_NAME'
11
+ JENKINS_JOB_NAME_KEY = 'JOB_NAME'
12
+ GITHUB_ACTIONS_KEY = 'GITHUB_ACTIONS'
13
+ GITHUB_ACTIONS_SERVER_URL_KEY = 'GITHUB_SERVER_URL'
14
+ GITHUB_ACTIONS_REPOSITORY_KEY = 'GITHUB_REPOSITORY'
15
+ GITHUB_ACTIONS_RUN_ID_KEY = 'GITHUB_RUN_ID'
16
+ GITHUB_ACTIONS_RUN_NUMBER_KEY = 'GITHUB_RUN_NUMBER'
17
+ GITHUB_ACTIONS_JOB_KEY = 'GITHUB_JOB'
18
+ GITHUB_ACTIONS_WORKFLOW_KEY = 'GITHUB_WORKFLOW'
19
+ GITHUB_PULL_REQUEST_URL_KEY = 'GITHUB_PULL_REQUEST_URL'
20
+ CIRCLECI_KEY = 'CIRCLECI'
21
+ CIRCLECI_BUILD_URL_KEY = 'CIRCLE_BUILD_URL'
22
+ CIRCLECI_BUILD_NUM_KEY = 'CIRCLE_BUILD_NUM'
23
+ CIRCLECI_JOB_KEY = 'CIRCLE_JOB'
24
+
25
+ GITHUB_PR_REGEX = re.compile(r"^https://github\.com/[^/]+/[^/]+/pull/\d+$")
26
+
27
+
28
+ class LinkKind(Enum):
29
+
30
+ LINK_KIND_UNSPECIFIED = 0
31
+ CUSTOM_LINK = 1
32
+ JENKINS = 2
33
+ GITHUB_ACTIONS = 3
34
+ GITHUB_PULL_REQUEST = 4
35
+ CIRCLECI = 5
36
+
37
+
38
+ def capture_link(env: Mapping[str, str]) -> List[Dict[str, str]]:
39
+ links = []
40
+
41
+ # see https://launchableinc.atlassian.net/wiki/spaces/PRODUCT/pages/612892698/ for the list of
42
+ # environment variables used by various CI systems
43
+ if env.get(JENKINS_URL_KEY):
44
+ links.append({
45
+ "kind": LinkKind.JENKINS.name, "url": env.get(JENKINS_BUILD_URL_KEY, ""),
46
+ "title": f"{env.get(JENKINS_JOB_NAME_KEY)} {env.get(JENKINS_BUILD_DISPLAY_NAME_KEY)}"
47
+ })
48
+ if env.get(GITHUB_ACTIONS_KEY):
49
+ links.append({
50
+ "kind": LinkKind.GITHUB_ACTIONS.name,
51
+ "url": f"{env.get(GITHUB_ACTIONS_SERVER_URL_KEY)}/{env.get(GITHUB_ACTIONS_REPOSITORY_KEY)}"
52
+ f"/actions/runs/{env.get(GITHUB_ACTIONS_RUN_ID_KEY)}",
53
+ # the nomenclature in GitHub PR comment from GHA has the optional additional part "(a,b,c)" that refers
54
+ # to the matrix, but that doesn't appear to be available as env var. Interestingly, run numbers are not
55
+ # included. Maybe it was seen as too much details and unnecessary for deciding which link to click?
56
+ "title": f"{env.get(GITHUB_ACTIONS_WORKFLOW_KEY)} / {env.get(GITHUB_ACTIONS_JOB_KEY)} "
57
+ f"#{env.get(GITHUB_ACTIONS_RUN_NUMBER_KEY)}"
58
+ })
59
+ if env.get(GITHUB_PULL_REQUEST_URL_KEY):
60
+ # TODO: where is this environment variable coming from?
61
+ links.append({
62
+ "kind": LinkKind.GITHUB_PULL_REQUEST.name,
63
+ "url": env.get(GITHUB_PULL_REQUEST_URL_KEY, ""),
64
+ "title": ""
65
+ })
66
+ if env.get(CIRCLECI_KEY):
67
+ # Their UI is organized as "project > branch > workflow > job (buildNum)" and it's not clear to me
68
+ # how much of that information should be present in title.
69
+ links.append({
70
+ "kind": LinkKind.CIRCLECI.name, "url": env.get(CIRCLECI_BUILD_URL_KEY, ""),
71
+ "title": f"{env.get(CIRCLECI_JOB_KEY)} ({env.get(CIRCLECI_BUILD_NUM_KEY)})"
72
+ })
73
+
74
+ return links
75
+
76
+
77
+ def capture_links_from_options(link_options: Sequence[KeyValue]) -> List[Dict[str, str]]:
78
+ """
79
+ Validate user-provided link options, inferring the kind when not explicitly specified.
80
+
81
+ Each link option is expected in the format "kind|title=url" or "title=url".
82
+ If the kind is not provided, it infers the kind based on the URL pattern.
83
+
84
+ Returns:
85
+ A list of dictionaries, where each dictionary contains the validated title, URL, and kind for each link.
86
+
87
+ Raises:
88
+ BadCmdLineException: If an invalid kind is provided or URL doesn't match with the specified kind.
89
+ """
90
+ links = []
91
+ for kv in link_options:
92
+ k = kv.key
93
+ url = kv.value.strip()
94
+
95
+ # if k,v in format "kind|title=url"
96
+ if '|' in k:
97
+ kind, title = (part.strip() for part in k.split('|', 1))
98
+ if kind not in _valid_kinds():
99
+ raise BadCmdLineException(
100
+ f"Invalid kind '{kind}' passed to --link option.\nSupported kinds are {_valid_kinds()}")
101
+
102
+ if not _url_matches_kind(url, kind):
103
+ raise BadCmdLineException(
104
+ f"Invalid url '{url}' passed to --link option.\nURL doesn't match with the specified kind '{kind}'")
105
+ # if k,v in format "title=url"
106
+ else:
107
+ kind = _infer_kinds(url)
108
+ title = k.strip()
109
+
110
+ links.append({
111
+ "title": title,
112
+ "url": url,
113
+ "kind": kind,
114
+ })
115
+
116
+ return links
117
+
118
+
119
+ def capture_links(link_options: Sequence[KeyValue], env: Mapping[str, str]) -> List[Dict[str, str]]:
120
+ links = capture_links_from_options(link_options)
121
+
122
+ env_links = capture_link(env)
123
+ for env_link in env_links:
124
+ if not _has_kind(links, env_link['kind']):
125
+ links.append(env_link)
126
+
127
+ return links
128
+
129
+
130
+ def _infer_kinds(url: str) -> str:
131
+ if GITHUB_PR_REGEX.match(url):
132
+ return LinkKind.GITHUB_PULL_REQUEST.name
133
+
134
+ return LinkKind.CUSTOM_LINK.name
135
+
136
+
137
+ def _has_kind(input_links: List[Dict[str, str]], kind: str) -> bool:
138
+ return any(link for link in input_links if link['kind'] == kind)
139
+
140
+
141
+ def _valid_kinds() -> List[str]:
142
+ return [kind.name for kind in LinkKind if kind != LinkKind.LINK_KIND_UNSPECIFIED]
143
+
144
+
145
+ def _url_matches_kind(url: str, kind: str) -> bool:
146
+ if kind == LinkKind.GITHUB_PULL_REQUEST.name:
147
+ return bool(GITHUB_PR_REGEX.match(url))
148
+
149
+ return True
@@ -0,0 +1,53 @@
1
+ import logging
2
+
3
+ LOG_LEVEL_DEFAULT = logging.WARNING
4
+ LOG_LEVEL_DEFAULT_STR = "DEFAULT"
5
+ LOG_LEVEL_AUDIT = 25
6
+ LOG_LEVEL_AUDIT_STR = "AUDIT"
7
+
8
+ AUDIT_LOG_FORMAT = "{0}send request method:{1} path:{2} headers:{3} args:{4}"
9
+
10
+ logging.addLevelName(LOG_LEVEL_AUDIT, "AUDIT")
11
+
12
+
13
+ def get_log_level(level=str) -> int:
14
+ level = level.lower()
15
+
16
+ if level == "critical":
17
+ return logging.CRITICAL
18
+ elif level == "error":
19
+ return logging.ERROR
20
+ elif level == "warn" or level == "warning":
21
+ return logging.WARNING
22
+ elif level == "audit":
23
+ return LOG_LEVEL_AUDIT
24
+ elif level == "info":
25
+ return logging.INFO
26
+ elif level == "debug":
27
+ return logging.DEBUG
28
+ else:
29
+ return LOG_LEVEL_DEFAULT
30
+
31
+
32
+ class Logger(object):
33
+ def __init__(self, name: str = "smart-tests"):
34
+ logger = logging.getLogger(name)
35
+ self.logger = logger
36
+
37
+ def audit(self, msg, *args, **kargs):
38
+ self.logger.log(LOG_LEVEL_AUDIT, msg, *args, **kargs)
39
+
40
+ def debug(self, msg, *args, **kargs):
41
+ self.logger.debug(msg, *args, **kargs)
42
+
43
+ def info(self, msg, *args, **kargs):
44
+ self.logger.info(msg, *args, **kargs)
45
+
46
+ def warning(self, msg, *args, **kargs):
47
+ self.logger.warning(msg, *args, **kargs)
48
+
49
+ def error(self, msg, *args, **kargs):
50
+ self.logger.error(msg, *args, **kargs)
51
+
52
+ def critical(self, msg, *args, **kargs):
53
+ self.logger.critical(msg, *args, **kargs)
@@ -0,0 +1,2 @@
1
+ NO_BUILD_BUILD_NAME = "nameless"
2
+ NO_BUILD_TEST_SESSION_ID = 0
@@ -0,0 +1,119 @@
1
+ import re
2
+ import sys
3
+ from typing import Callable, Dict, List
4
+ from xml.sax import make_parser
5
+ from xml.sax.handler import ContentHandler
6
+ from xml.sax.xmlreader import AttributesImpl
7
+
8
+ from smart_tests.args4p.exceptions import BadCmdLineException
9
+
10
+
11
+ class Element:
12
+ """Just like DOM element except it only knows about ancestors"""
13
+
14
+ # XML tag name
15
+ # name: str
16
+
17
+ # parent element
18
+ # parent: Element
19
+
20
+ # attributes. 'Attributes' class doesn't seem to exist
21
+ # attrs: object
22
+
23
+ # tags captured at this context
24
+ # tags: Dict[str,object]
25
+
26
+ def __init__(self, parent: 'Element', name: str, attrs: AttributesImpl):
27
+ self.name = name
28
+ self.attrs = attrs
29
+ self.parent = parent
30
+ # start with a copy of parents, and we modify it with ours
31
+ self.tags: Dict[str, object] = dict()
32
+ self.tags = parent.tags.copy() if parent else dict()
33
+
34
+ def __str__(self):
35
+ return "%s %s" % (self.name, self.tags)
36
+
37
+
38
+ class TagMatcher:
39
+ """Matches to an attribute of an XML element and captures its value as a 'tag' """
40
+
41
+ # XML tag name to match
42
+ # element: str
43
+ # XML attribute name to match
44
+ # attr: str
45
+
46
+ # Name of the variable to capture
47
+ # var: str
48
+
49
+ pattern = re.compile(r"(\w+|\*)/@([a-zA-Z_\-]+)={(\w+)}")
50
+
51
+ def __init__(self, element: str, attr: str, var: str):
52
+ self.element = element
53
+ self.attr = attr
54
+ self.var = var
55
+
56
+ def matches(self, e: Element) -> str | None:
57
+ return e.attrs.get(
58
+ self.attr) if self.element == e.name or self.element == "*" else None
59
+
60
+ @staticmethod
61
+ def parse(spec: str) -> 'TagMatcher':
62
+ """Parse a string like foo/@bar={zot}"""
63
+ m = TagMatcher.pattern.match(spec)
64
+ if m:
65
+ return TagMatcher(m.group(1), m.group(2), m.group(3))
66
+ else:
67
+ raise BadCmdLineException("Invalid tag spec: %s" % spec)
68
+
69
+
70
+ class SaxParser(ContentHandler):
71
+ """
72
+ Parse XML in a streaming manner, capturing attribute values as specified by TagMatchers
73
+ """
74
+
75
+ # represents the current element
76
+ context: Element | None = None
77
+
78
+ # matchers: List[TagMatcher]
79
+
80
+ # receiver: Callable[[Element],None]
81
+
82
+ def __init__(self, matchers: List[TagMatcher], receiver: Callable[[Element], None]):
83
+ super().__init__()
84
+ self.matchers = matchers
85
+ self.receiver = receiver
86
+
87
+ def startElement(self, tag, attrs):
88
+ self.context = Element(self.context, tag, attrs)
89
+
90
+ # match tags at this element
91
+ for m in self.matchers:
92
+ v = m.matches(self.context)
93
+ if v is not None:
94
+ self.context.tags[m.var] = v
95
+
96
+ # yield is more Pythonesque but because this is called from SAX parser
97
+ # I'm assuming that won't work
98
+ self.receiver(self.context)
99
+
100
+ def endElement(self, tag):
101
+ self.context = self.context.parent
102
+
103
+ def parse(self, body):
104
+ p = make_parser()
105
+ p.setContentHandler(self)
106
+ p.parse(body)
107
+
108
+
109
+ # Scaffold JUnit parser
110
+ # python -m launchable.utils.sax < result.xml
111
+ if __name__ == "__main__":
112
+ def print_test_case(e: Element):
113
+ if e.name == "testcase":
114
+ print(e)
115
+
116
+ SaxParser([
117
+ TagMatcher.parse("testcase/@name={testcaseName}"),
118
+ TagMatcher.parse("testsuite/@timestamp={timestamp}")
119
+ ], print_test_case).parse(sys.stdin)
@@ -0,0 +1,73 @@
1
+ # Utilities for TestSession.
2
+ # Named `session.py` to avoid confusion with test files.
3
+
4
+ import re
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from typing import Tuple
8
+
9
+ import click
10
+ from requests import HTTPError
11
+
12
+ from smart_tests.args4p.exceptions import BadCmdLineException
13
+ from smart_tests.utils.smart_tests_client import SmartTestsClient
14
+ from smart_tests.utils.tracking import Tracking
15
+
16
+
17
+ @dataclass
18
+ class TestSession:
19
+ id: int
20
+ build_id: int
21
+ build_name: str
22
+ observation_mode: bool
23
+ name: str | None = None
24
+
25
+
26
+ def get_session(session: str, client: SmartTestsClient) -> TestSession:
27
+ build_name, test_session_id = parse_session(session)
28
+
29
+ subpath = f"builds/{build_name}/test_sessions/{test_session_id}"
30
+ res = client.request("get", subpath)
31
+
32
+ try:
33
+ res.raise_for_status()
34
+ except HTTPError as e:
35
+ if e.response.status_code == 404:
36
+ # TODO(Konboi): move subset.print_error_and_die to util and use it
37
+ msg = f"Session {session} was not found. Make sure to run `smart-tests record session --build {build_name}` before you run this command" # noqa E501
38
+ click.secho(msg, fg='red', err=True)
39
+ if client.tracking_client:
40
+ client.tracking_client.send_error_event(event_name=Tracking.ErrorEvent.USER_ERROR, stack_trace=msg)
41
+ sys.exit(1)
42
+ raise
43
+
44
+ test_session = res.json()
45
+
46
+ return TestSession(
47
+ id=test_session.get("id"),
48
+ build_id=test_session.get("buildId"),
49
+ build_name=test_session.get("buildNumber"),
50
+ observation_mode=test_session.get("isObservation"),
51
+ name=test_session.get("name"),
52
+ )
53
+
54
+
55
+ def parse_session(session: str) -> Tuple[str, int]:
56
+ """Parse session to extract build name and test session id.
57
+
58
+ Args:
59
+ session: Session in format "builds/{build_name}/test_sessions/{test_session_id}"
60
+
61
+ Returns:
62
+ Tuple of (build_name, test_session_id)
63
+
64
+ Raises:
65
+ ValueError: If session_id format is invalid
66
+ """
67
+ match = re.match(r"builds/([^/]+)/test_sessions/(.+)", session)
68
+
69
+ if match:
70
+ return match.group(1), int(match.group(2))
71
+ else:
72
+ raise BadCmdLineException(
73
+ f"Invalid session ID format: {session}. Expected format: builds/{{build_name}}/test_sessions/{{test_session_id}}")
@@ -0,0 +1,134 @@
1
+ import os
2
+ from typing import BinaryIO, Dict
3
+
4
+ import click
5
+ import requests
6
+ from requests import HTTPError, Session, Timeout
7
+
8
+ from smart_tests.utils.http_client import _HttpClient, _join_paths
9
+ from smart_tests.utils.tracking import Tracking, TrackingClient # type: ignore
10
+
11
+ from ..app import Application
12
+ from ..args4p.exceptions import BadCmdLineException
13
+ from .authentication import get_org_workspace
14
+ from .env_keys import REPORT_ERROR_KEY
15
+
16
+
17
+ class SmartTestsClient:
18
+ def __init__(self, tracking_client: TrackingClient | None = None, base_url: str = "", session: Session | None = None,
19
+ app: Application | None = None):
20
+ self.http_client = _HttpClient(
21
+ base_url=base_url,
22
+ session=session,
23
+ app=app
24
+ )
25
+ self.tracking_client = tracking_client
26
+ self.organization, self.workspace = get_org_workspace()
27
+ if self.organization is None or self.workspace is None:
28
+ raise BadCmdLineException(
29
+ "Could not identify a Smart Tests organization/workspace. "
30
+ "Confirm that you set SMART_TESTS_TOKEN "
31
+ "(or SMART_TESTS_ORGANIZATION and SMART_TESTS_WORKSPACE) environment variable(s)\n"
32
+ "See https://help.launchableinc.com/sending-data-to-launchable/using-the-launchable-cli/getting-started/")
33
+ self._workspace_state_cache: Dict[str, str | bool] | None = None
34
+
35
+ def request(
36
+ self,
37
+ method: str,
38
+ sub_path: str,
39
+ payload: dict | BinaryIO | None = None,
40
+ params: dict | None = None,
41
+ timeout: tuple[int, int] = (5, 60),
42
+ compress: bool = False,
43
+ additional_headers: dict | None = None,
44
+ ) -> requests.Response:
45
+ path = _join_paths(
46
+ f"/intake/organizations/{self.organization}/workspaces/{self.workspace}",
47
+ sub_path
48
+ )
49
+
50
+ # report an error and bail out
51
+ def track(event_name: Tracking.ErrorEvent, e: Exception):
52
+ if self.tracking_client:
53
+ self.tracking_client.send_error_event(
54
+ event_name=event_name,
55
+ stack_trace=str(e),
56
+ api=sub_path,
57
+ )
58
+ raise e
59
+
60
+ try:
61
+ response = self.http_client.request(
62
+ method=method,
63
+ path=path,
64
+ payload=payload,
65
+ params=params,
66
+ timeout=timeout,
67
+ compress=compress,
68
+ additional_headers=additional_headers
69
+ )
70
+ return response
71
+ except ConnectionError as e:
72
+ track(Tracking.ErrorEvent.NETWORK_ERROR, e)
73
+ except Timeout as e:
74
+ track(Tracking.ErrorEvent.TIMEOUT_ERROR, e)
75
+ except HTTPError as e:
76
+ track(Tracking.ErrorEvent.UNEXPECTED_HTTP_STATUS_ERROR, e)
77
+ except Exception as e:
78
+ track(Tracking.ErrorEvent.INTERNAL_SERVER_ERROR, e)
79
+
80
+ # should never come here, but needed to make type checker happy
81
+ assert False
82
+
83
+ def print_exception_and_recover(self, e: Exception, warning: str | None = None, warning_color='yellow'):
84
+ """
85
+ Print the exception raised from the request method, then recover from it
86
+
87
+ :param warning: optional warning message to contextualize the HTTP error
88
+ """
89
+
90
+ # a diagnostics flag to abort and report the details
91
+ if os.getenv(REPORT_ERROR_KEY):
92
+ raise e
93
+
94
+ click.echo(e, err=True)
95
+ if isinstance(e, HTTPError):
96
+ # if the payload is present, report that as well to assist troubleshooting
97
+ res = e.response
98
+ if res and res.text:
99
+ click.echo(res.text, err=True)
100
+
101
+ if warning:
102
+ click.secho(warning, fg=warning_color, err=True)
103
+
104
+ def base_url(self) -> str:
105
+ return self.http_client.base_url
106
+
107
+ def is_fail_fast_mode(self) -> bool:
108
+ state = self._get_workspace_state()
109
+ return state.get('fail_fast_mode', False)
110
+
111
+ def is_pts_v2_enabled(self) -> bool:
112
+ state = self._get_workspace_state()
113
+ return state.get('pts_v2', False)
114
+
115
+ def _get_workspace_state(self) -> dict:
116
+ """
117
+ Get the current state of the workspace.
118
+ """
119
+ if self._workspace_state_cache is not None:
120
+ return self._workspace_state_cache
121
+ try:
122
+ res = self.request("get", "state")
123
+ res.raise_for_status()
124
+
125
+ state = res.json()
126
+ self._workspace_state_cache = {
127
+ 'fail_fast_mode': state.get('isFailFastMode', False),
128
+ 'pts_v2': state.get('isPtsV2Enabled', False),
129
+ }
130
+ return self._workspace_state_cache
131
+ except Exception as e:
132
+ self.print_exception_and_recover(e, "Failed to get workspace state")
133
+
134
+ return {}
@@ -0,0 +1,12 @@
1
+ import subprocess
2
+
3
+
4
+ def check_output(*args, **kwargs):
5
+ """A wrapper for subprocess.check_output
6
+
7
+ In Windows, subprocess.check_output is used internally in one of those
8
+ dependencies. If we mock out subprocess.check_output, it also traps those
9
+ internall calls, making tests fail. This wrapper is a point to mock only
10
+ Smart Tests CLI initiated calls.
11
+ """
12
+ return subprocess.check_output(*args, **kwargs)