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.
- smart_tests/__init__.py +0 -0
- smart_tests/__main__.py +60 -0
- smart_tests/app.py +67 -0
- smart_tests/args4p/README.md +102 -0
- smart_tests/args4p/__init__.py +13 -0
- smart_tests/args4p/argument.py +45 -0
- smart_tests/args4p/command.py +593 -0
- smart_tests/args4p/converters/__init__.py +75 -0
- smart_tests/args4p/decorators.py +98 -0
- smart_tests/args4p/exceptions.py +12 -0
- smart_tests/args4p/option.py +85 -0
- smart_tests/args4p/parameter.py +84 -0
- smart_tests/args4p/typer/__init__.py +42 -0
- smart_tests/commands/__init__.py +0 -0
- smart_tests/commands/compare/__init__.py +11 -0
- smart_tests/commands/compare/subsets.py +58 -0
- smart_tests/commands/detect_flakes.py +105 -0
- smart_tests/commands/inspect/__init__.py +13 -0
- smart_tests/commands/inspect/model.py +52 -0
- smart_tests/commands/inspect/subset.py +138 -0
- smart_tests/commands/record/__init__.py +19 -0
- smart_tests/commands/record/attachment.py +38 -0
- smart_tests/commands/record/build.py +356 -0
- smart_tests/commands/record/case_event.py +190 -0
- smart_tests/commands/record/commit.py +157 -0
- smart_tests/commands/record/session.py +120 -0
- smart_tests/commands/record/tests.py +498 -0
- smart_tests/commands/stats/__init__.py +11 -0
- smart_tests/commands/stats/test_sessions.py +45 -0
- smart_tests/commands/subset.py +567 -0
- smart_tests/commands/test_path_writer.py +51 -0
- smart_tests/commands/verify.py +153 -0
- smart_tests/jar/exe_deploy.jar +0 -0
- smart_tests/plugins/__init__.py +0 -0
- smart_tests/test_runners/__init__.py +0 -0
- smart_tests/test_runners/adb.py +24 -0
- smart_tests/test_runners/ant.py +35 -0
- smart_tests/test_runners/bazel.py +103 -0
- smart_tests/test_runners/behave.py +62 -0
- smart_tests/test_runners/codeceptjs.py +33 -0
- smart_tests/test_runners/ctest.py +164 -0
- smart_tests/test_runners/cts.py +189 -0
- smart_tests/test_runners/cucumber.py +451 -0
- smart_tests/test_runners/cypress.py +46 -0
- smart_tests/test_runners/dotnet.py +106 -0
- smart_tests/test_runners/file.py +20 -0
- smart_tests/test_runners/flutter.py +251 -0
- smart_tests/test_runners/go_test.py +99 -0
- smart_tests/test_runners/googletest.py +34 -0
- smart_tests/test_runners/gradle.py +96 -0
- smart_tests/test_runners/jest.py +52 -0
- smart_tests/test_runners/maven.py +149 -0
- smart_tests/test_runners/minitest.py +40 -0
- smart_tests/test_runners/nunit.py +190 -0
- smart_tests/test_runners/playwright.py +252 -0
- smart_tests/test_runners/prove.py +74 -0
- smart_tests/test_runners/pytest.py +358 -0
- smart_tests/test_runners/raw.py +238 -0
- smart_tests/test_runners/robot.py +125 -0
- smart_tests/test_runners/rspec.py +5 -0
- smart_tests/test_runners/smart_tests.py +235 -0
- smart_tests/test_runners/vitest.py +49 -0
- smart_tests/test_runners/xctest.py +79 -0
- smart_tests/testpath.py +154 -0
- smart_tests/utils/__init__.py +0 -0
- smart_tests/utils/authentication.py +78 -0
- smart_tests/utils/ci_provider.py +7 -0
- smart_tests/utils/commands.py +14 -0
- smart_tests/utils/commit_ingester.py +59 -0
- smart_tests/utils/common_tz.py +12 -0
- smart_tests/utils/edit_distance.py +11 -0
- smart_tests/utils/env_keys.py +19 -0
- smart_tests/utils/exceptions.py +34 -0
- smart_tests/utils/fail_fast_mode.py +99 -0
- smart_tests/utils/file_name_pattern.py +4 -0
- smart_tests/utils/git_log_parser.py +53 -0
- smart_tests/utils/glob.py +44 -0
- smart_tests/utils/gzipgen.py +46 -0
- smart_tests/utils/http_client.py +169 -0
- smart_tests/utils/java.py +61 -0
- smart_tests/utils/link.py +149 -0
- smart_tests/utils/logger.py +53 -0
- smart_tests/utils/no_build.py +2 -0
- smart_tests/utils/sax.py +119 -0
- smart_tests/utils/session.py +73 -0
- smart_tests/utils/smart_tests_client.py +134 -0
- smart_tests/utils/subprocess.py +12 -0
- smart_tests/utils/tracking.py +95 -0
- smart_tests/utils/typer_types.py +241 -0
- smart_tests/version.py +7 -0
- smart_tests_cli-2.0.0.dist-info/METADATA +168 -0
- smart_tests_cli-2.0.0.dist-info/RECORD +96 -0
- smart_tests_cli-2.0.0.dist-info/WHEEL +5 -0
- smart_tests_cli-2.0.0.dist-info/entry_points.txt +2 -0
- smart_tests_cli-2.0.0.dist-info/licenses/LICENSE.txt +202 -0
- 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)
|
smart_tests/utils/sax.py
ADDED
|
@@ -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)
|