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,78 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
import smart_tests.args4p.typer as typer
|
|
8
|
+
|
|
9
|
+
from .env_keys import ORGANIZATION_KEY, WORKSPACE_KEY, get_token
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_org_workspace():
|
|
13
|
+
token = get_token()
|
|
14
|
+
if token:
|
|
15
|
+
try:
|
|
16
|
+
_, user, _ = token.split(":", 2)
|
|
17
|
+
org, workspace = user.split("/", 1)
|
|
18
|
+
return org, workspace
|
|
19
|
+
except ValueError:
|
|
20
|
+
return None, None
|
|
21
|
+
|
|
22
|
+
return os.getenv(ORGANIZATION_KEY), os.getenv(WORKSPACE_KEY)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ensure_org_workspace() -> Tuple[str, str]:
|
|
26
|
+
org, workspace = get_org_workspace()
|
|
27
|
+
if org is None or workspace is None:
|
|
28
|
+
click.secho(
|
|
29
|
+
"Could not identify Smart Tests organization/workspace. "
|
|
30
|
+
"Please confirm if you set SMART_TESTS_TOKEN "
|
|
31
|
+
"(or LAUNCHABLE_TOKEN for backward compatibility) or SMART_TESTS_ORGANIZATION and "
|
|
32
|
+
"SMART_TESTS_WORKSPACE environment variables", fg='red', err=True)
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
return org, workspace
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def authentication_headers():
|
|
38
|
+
token = get_token()
|
|
39
|
+
if token:
|
|
40
|
+
return {'Authorization': f'Bearer {token}'}
|
|
41
|
+
|
|
42
|
+
if os.getenv('EXPERIMENTAL_GITHUB_OIDC_TOKEN_AUTH'):
|
|
43
|
+
req_url = os.getenv('ACTIONS_ID_TOKEN_REQUEST_URL')
|
|
44
|
+
rt_token = os.getenv('ACTIONS_ID_TOKEN_REQUEST_TOKEN')
|
|
45
|
+
if not req_url or not rt_token:
|
|
46
|
+
click.secho(
|
|
47
|
+
"GitHub Actions OIDC tokens cannot be retrieved."
|
|
48
|
+
"Confirm that you have added necessary permissions following "
|
|
49
|
+
"https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings", # noqa: E501
|
|
50
|
+
fg='red', err=True)
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
r = requests.get(req_url,
|
|
53
|
+
headers={
|
|
54
|
+
'Authorization': f'Bearer {rt_token}',
|
|
55
|
+
'Accept': 'application/json; api-version=2.0',
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
})
|
|
58
|
+
r.raise_for_status()
|
|
59
|
+
return {"Authorization": f"Bearer {r.json()['value']}"}
|
|
60
|
+
|
|
61
|
+
if os.getenv('GITHUB_ACTIONS'):
|
|
62
|
+
headers = {
|
|
63
|
+
'GitHub-Actions': os.environ['GITHUB_ACTIONS'],
|
|
64
|
+
'GitHub-Run-Id': os.environ['GITHUB_RUN_ID'],
|
|
65
|
+
'GitHub-Repository': os.environ['GITHUB_REPOSITORY'],
|
|
66
|
+
'GitHub-Workflow': os.environ['GITHUB_WORKFLOW'],
|
|
67
|
+
'GitHub-Run-Number': os.environ['GITHUB_RUN_NUMBER'],
|
|
68
|
+
'GitHub-Event-Name': os.environ['GITHUB_EVENT_NAME'],
|
|
69
|
+
'GitHub-Sha': os.environ['GITHUB_SHA'],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# GITHUB_PR_HEAD_SHA might not exist
|
|
73
|
+
pr_head_sha = os.getenv('GITHUB_PR_HEAD_SHA')
|
|
74
|
+
if pr_head_sha:
|
|
75
|
+
headers['GitHub-Pr-Head-Sha'] = pr_head_sha
|
|
76
|
+
|
|
77
|
+
return headers
|
|
78
|
+
return {}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Command(Enum):
|
|
5
|
+
VERIFY = 'VERIFY'
|
|
6
|
+
RECORD_TESTS = 'RECORD_TESTS'
|
|
7
|
+
RECORD_BUILD = 'RECORD_BUILD'
|
|
8
|
+
RECORD_SESSION = 'RECORD_SESSION'
|
|
9
|
+
SUBSET = 'SUBSET'
|
|
10
|
+
COMMIT = 'COMMIT'
|
|
11
|
+
DETECT_FLAKE = 'DETECT_FLAKE'
|
|
12
|
+
|
|
13
|
+
def display_name(self):
|
|
14
|
+
return self.value.lower().replace('_', ' ')
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from datetime import tzinfo
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
from ..app import Application
|
|
6
|
+
from .git_log_parser import GitCommit
|
|
7
|
+
from .smart_tests_client import SmartTestsClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _sha256(s: str) -> str:
|
|
11
|
+
return hashlib.sha256(s.encode('utf8')).hexdigest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _format_tzinfo(tz: tzinfo | None) -> int:
|
|
15
|
+
if not tz:
|
|
16
|
+
return 0
|
|
17
|
+
delta = tz.utcoffset(None)
|
|
18
|
+
if not delta:
|
|
19
|
+
return 0
|
|
20
|
+
return round(delta.total_seconds() / 60)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _convert_git_commit(commit: GitCommit) -> Dict:
|
|
24
|
+
changed_files = []
|
|
25
|
+
for changed_file in commit.changed_files:
|
|
26
|
+
cf = dict()
|
|
27
|
+
cf['linesAdded'] = changed_file.added
|
|
28
|
+
cf['linesDeleted'] = changed_file.deleted
|
|
29
|
+
cf['status'] = 'MODIFY'
|
|
30
|
+
cf['path'] = changed_file.path
|
|
31
|
+
cf['pathTo'] = changed_file.path
|
|
32
|
+
changed_files.append(cf)
|
|
33
|
+
parents = dict()
|
|
34
|
+
if len(commit.parents) > 0:
|
|
35
|
+
# We don't know which diff is for which parent. Use the first parent.
|
|
36
|
+
parents[commit.parents[0]] = changed_files
|
|
37
|
+
for parent in commit.parents[1:len(commit.parents)]:
|
|
38
|
+
parents[parent] = []
|
|
39
|
+
|
|
40
|
+
d = dict()
|
|
41
|
+
d['commitHash'] = commit.commit_hash
|
|
42
|
+
d['authorEmailAddress'] = _sha256(commit.author_email)
|
|
43
|
+
d['authorWhen'] = round(commit.author_time.timestamp() * 1000)
|
|
44
|
+
d['authorTimezoneOffset'] = _format_tzinfo(commit.author_time.tzinfo)
|
|
45
|
+
d['committerEmailAddress'] = _sha256(commit.committer_email)
|
|
46
|
+
d['committerWhen'] = round(commit.committer_time.timestamp() * 1000)
|
|
47
|
+
d['committerTimezoneOffset'] = _format_tzinfo(commit.committer_time.tzinfo)
|
|
48
|
+
d['parentHashes'] = parents
|
|
49
|
+
return d
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def upload_commits(commits: List[GitCommit], app: Application):
|
|
53
|
+
payload = {
|
|
54
|
+
'commits': [_convert_git_commit(commit) for commit in commits]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
client = SmartTestsClient(app=app)
|
|
58
|
+
res = client.request("post", "commits/collect", payload=payload)
|
|
59
|
+
res.raise_for_status()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from dateutil.tz import gettz
|
|
2
|
+
|
|
3
|
+
# The dateutil library does not recognize timezone abbreviations (like "PDT" or "JST") by default.
|
|
4
|
+
# To handle these, we manually map common abbreviations to their corresponding timezones.
|
|
5
|
+
# See: https://github.com/dateutil/dateutil/issues/932
|
|
6
|
+
COMMON_TIMEZONES = {
|
|
7
|
+
"UTC": gettz("UTC"),
|
|
8
|
+
# https://time.now/timezones/pdt/
|
|
9
|
+
"PDT": gettz("America/Los_Angeles"),
|
|
10
|
+
# https://time.now/timezones/jst/
|
|
11
|
+
"JST": gettz("Asia/Tokyo"),
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
def edit_distance(s1, s2):
|
|
2
|
+
distances = range(len(s1) + 1)
|
|
3
|
+
for i2, c2 in enumerate(s2):
|
|
4
|
+
distances_ = [i2 + 1]
|
|
5
|
+
for i1, c1 in enumerate(s1):
|
|
6
|
+
if c1 == c2:
|
|
7
|
+
distances_.append(distances[i1])
|
|
8
|
+
else:
|
|
9
|
+
distances_.append(1 + min((distances[i1], distances[i1 + 1], distances_[-1])))
|
|
10
|
+
distances = distances_
|
|
11
|
+
return distances[-1]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
REPORT_ERROR_KEY = "SMART_TESTS_REPORT_ERROR"
|
|
4
|
+
TOKEN_KEY = "SMART_TESTS_TOKEN"
|
|
5
|
+
ORGANIZATION_KEY = "SMART_TESTS_ORGANIZATION"
|
|
6
|
+
WORKSPACE_KEY = "SMART_TESTS_WORKSPACE"
|
|
7
|
+
BASE_URL_KEY = "SMART_TESTS_BASE_URL"
|
|
8
|
+
SKIP_TIMEOUT_RETRY = "SMART_TESTS_SKIP_TIMEOUT_RETRY"
|
|
9
|
+
COMMIT_TIMEOUT = "SMART_TESTS_COMMIT_TIMEOUT"
|
|
10
|
+
SKIP_CERT_VERIFICATION = "SMART_TESTS_SKIP_CERT_VERIFICATION"
|
|
11
|
+
SESSION_DIR_KEY = "SMART_TESTS_SESSION_DIR"
|
|
12
|
+
|
|
13
|
+
# Legacy token key for backward compatibility
|
|
14
|
+
LEGACY_TOKEN_KEY = "LAUNCHABLE_TOKEN"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_token():
|
|
18
|
+
"""Get token with backward compatibility for LAUNCHABLE_TOKEN."""
|
|
19
|
+
return os.getenv(TOKEN_KEY) or os.getenv(LEGACY_TOKEN_KEY)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# TODO: add cli-specific custom exceptions
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from smart_tests.utils.tracking import Tracking, TrackingClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ParseSessionException(Exception):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
session: str,
|
|
13
|
+
message: str = "Wrong session format; session format is like 'builds/<build name>/test_sessions/<test session id>'.",
|
|
14
|
+
):
|
|
15
|
+
self.session = session
|
|
16
|
+
self.message = f"{message}: {self.session}"
|
|
17
|
+
super().__init__(self.message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidJUnitXMLException(Exception):
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
filename: str,
|
|
24
|
+
message: str = "Invalid JUnit XML file format",
|
|
25
|
+
):
|
|
26
|
+
self.filename = filename
|
|
27
|
+
self.message = f"{message}: {filename}"
|
|
28
|
+
super().__init__(self.message)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_error_and_die(msg: str, tracking_client: TrackingClient, event: Tracking.ErrorEvent):
|
|
32
|
+
click.secho(msg, fg='red', err=True)
|
|
33
|
+
tracking_client.send_error_event(event_name=event, stack_trace=msg)
|
|
34
|
+
sys.exit(1)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import List, Optional, Sequence, Tuple
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from .commands import Command
|
|
7
|
+
|
|
8
|
+
_fail_fast_mode_cache: Optional[bool] = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_fail_fast_mode(enabled: bool):
|
|
12
|
+
global _fail_fast_mode_cache
|
|
13
|
+
_fail_fast_mode_cache = enabled
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_fail_fast_mode() -> bool:
|
|
17
|
+
if _fail_fast_mode_cache:
|
|
18
|
+
return _fail_fast_mode_cache
|
|
19
|
+
|
|
20
|
+
# Default to False if not set
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def warn_and_exit_if_fail_fast_mode(message: str):
|
|
25
|
+
color = 'red' if is_fail_fast_mode() else 'yellow'
|
|
26
|
+
click.secho(message, fg=color, err=True)
|
|
27
|
+
if is_fail_fast_mode():
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FailFastModeValidateParams:
|
|
32
|
+
def __init__(self, command: Command, build: Optional[str] = None, is_no_build: bool = False,
|
|
33
|
+
test_suite: Optional[str] = None, session: Optional[str] = None,
|
|
34
|
+
links: Sequence[Tuple[str, str]] = (), is_observation: bool = False,
|
|
35
|
+
flavor: Sequence[Tuple[str, str]] = ()):
|
|
36
|
+
self.command = command
|
|
37
|
+
self.build = build
|
|
38
|
+
self.is_no_build = is_no_build
|
|
39
|
+
self.test_suite = test_suite
|
|
40
|
+
self.session = session
|
|
41
|
+
self.links = links
|
|
42
|
+
self.is_observation = is_observation
|
|
43
|
+
self.flavor = flavor
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fail_fast_mode_validate(params: FailFastModeValidateParams):
|
|
47
|
+
if not is_fail_fast_mode():
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
if params.command == Command.RECORD_SESSION:
|
|
51
|
+
_validate_record_session(params)
|
|
52
|
+
if params.command == Command.SUBSET:
|
|
53
|
+
_validate_subset(params)
|
|
54
|
+
if params.command == Command.RECORD_TESTS:
|
|
55
|
+
_validate_record_tests(params)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_require_session_option(params: FailFastModeValidateParams) -> List[str]:
|
|
59
|
+
errors: List[str] = []
|
|
60
|
+
cmd_name = params.command.display_name()
|
|
61
|
+
if params.session:
|
|
62
|
+
if params.test_suite:
|
|
63
|
+
errors.append("`--test-suite` option was ignored in the {} command. Add `--test-suite` option to the `record session` command instead.".format(cmd_name)) # noqa: E501
|
|
64
|
+
|
|
65
|
+
if params.is_observation:
|
|
66
|
+
errors.append(
|
|
67
|
+
"`--observation` was ignored in the {} command. Add `--observation` option to the `record session` command instead.".format(cmd_name)) # noqa: E501
|
|
68
|
+
|
|
69
|
+
if len(params.flavor) > 0:
|
|
70
|
+
errors.append(
|
|
71
|
+
"`--flavor` option was ignored in the {} command. Add `--flavor` option to the `record session` command instead.".format(cmd_name)) # noqa: E501
|
|
72
|
+
|
|
73
|
+
if len(params.links) > 0:
|
|
74
|
+
errors.append(
|
|
75
|
+
"`--link` option was ignored in the {} command. Add `link` option to the `record session` command instead.".format(cmd_name)) # noqa: E501
|
|
76
|
+
|
|
77
|
+
return errors
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_record_session(params: FailFastModeValidateParams):
|
|
81
|
+
# Now, there isn't any validation for the `record session` command in fail-fast mode.
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validate_subset(params: FailFastModeValidateParams):
|
|
86
|
+
errors = _validate_require_session_option(params)
|
|
87
|
+
_exit_if_errors(errors)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_record_tests(params: FailFastModeValidateParams):
|
|
91
|
+
errors = _validate_require_session_option(params)
|
|
92
|
+
_exit_if_errors(errors)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _exit_if_errors(errors: List[str]):
|
|
96
|
+
if errors:
|
|
97
|
+
msg = "\n".join(map(lambda x: click.style(x, fg='red'), errors))
|
|
98
|
+
click.echo(msg, err=True)
|
|
99
|
+
sys.exit(1)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections import namedtuple
|
|
3
|
+
from typing import Any, Dict, List, TextIO
|
|
4
|
+
|
|
5
|
+
import dateutil.parser
|
|
6
|
+
|
|
7
|
+
ChangedFile = namedtuple('ChangedFile', ['path', 'added', 'deleted'])
|
|
8
|
+
|
|
9
|
+
GitCommit = namedtuple('GitCommit', [
|
|
10
|
+
'commit_hash', 'parents', 'author_email', 'author_time', 'committer_email',
|
|
11
|
+
'committer_time', 'changed_files'
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_git_log(fp: TextIO) -> List[GitCommit]:
|
|
16
|
+
"""Parses the output of a git log command.
|
|
17
|
+
|
|
18
|
+
This parses the output of `git log --pretty='format:{"commit": "%H",
|
|
19
|
+
"parents": "%P", "authorEmail": "%ae", "authorTime": "%aI",
|
|
20
|
+
"committerEmail": "%ce", "committerTime": "%cI"}' --numstat`
|
|
21
|
+
"""
|
|
22
|
+
ret = []
|
|
23
|
+
meta: Dict[str, Any] = {}
|
|
24
|
+
files: List[ChangedFile] = []
|
|
25
|
+
for idx, line in enumerate(fp):
|
|
26
|
+
line = line.strip()
|
|
27
|
+
if line == '':
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
if line.startswith('{'):
|
|
31
|
+
if len(meta) != 0:
|
|
32
|
+
ret.append(GitCommit(changed_files=files, **meta))
|
|
33
|
+
meta = {}
|
|
34
|
+
files = []
|
|
35
|
+
d = json.loads(line)
|
|
36
|
+
meta['commit_hash'] = d['commit']
|
|
37
|
+
meta['parents'] = d['parents'].split(' ')
|
|
38
|
+
meta['author_email'] = d['authorEmail']
|
|
39
|
+
meta['author_time'] = dateutil.parser.parse(d['authorTime'])
|
|
40
|
+
meta['committer_email'] = d['committerEmail']
|
|
41
|
+
meta['committer_time'] = dateutil.parser.parse(
|
|
42
|
+
d['committerTime'])
|
|
43
|
+
elif line.startswith('-'):
|
|
44
|
+
# Ignore binary file changes
|
|
45
|
+
pass
|
|
46
|
+
else:
|
|
47
|
+
added, deleted, path = line.split('\t', 3)
|
|
48
|
+
files.append(ChangedFile(path=path, added=int(added), deleted=int(deleted)))
|
|
49
|
+
except Exception as e:
|
|
50
|
+
raise ValueError(f"Failed to parse the file at line {idx + 1}: {e}")
|
|
51
|
+
if len(meta) != 0:
|
|
52
|
+
ret.append(GitCommit(changed_files=files, **meta))
|
|
53
|
+
return ret
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Path name matching with extended GLOBs.
|
|
2
|
+
|
|
3
|
+
Primarily developed to interface with Maven, which supports "**", "*", and "?" as the special characters
|
|
4
|
+
"""
|
|
5
|
+
import re
|
|
6
|
+
from typing import Pattern
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_path_separator(c: str):
|
|
10
|
+
return c == '/' or c == '\\'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def compile(glob: str) -> Pattern:
|
|
14
|
+
"""Compiles a glob pattern like foo/**/*.txt into a """
|
|
15
|
+
# fnmatch.fnmatch is close but it doesn't deal with paths well, including
|
|
16
|
+
# '**'
|
|
17
|
+
|
|
18
|
+
p = ""
|
|
19
|
+
i = 0
|
|
20
|
+
n = len(glob)
|
|
21
|
+
while i < n:
|
|
22
|
+
c = glob[i]
|
|
23
|
+
i += 1
|
|
24
|
+
|
|
25
|
+
if c == '*':
|
|
26
|
+
if i < n and glob[i] == '*':
|
|
27
|
+
i += 1
|
|
28
|
+
if i < n and is_path_separator(glob[i]):
|
|
29
|
+
# '**/' matches any sub-directories or none
|
|
30
|
+
i += 1
|
|
31
|
+
p += "(.+[\\/])?"
|
|
32
|
+
else:
|
|
33
|
+
# '**' used like **Test.java. Is this even legal?
|
|
34
|
+
p += ".*"
|
|
35
|
+
else:
|
|
36
|
+
p += "[^\\/]*"
|
|
37
|
+
elif c == '?':
|
|
38
|
+
p += "[^\\/]"
|
|
39
|
+
elif is_path_separator(c):
|
|
40
|
+
p += "[\\/]"
|
|
41
|
+
else:
|
|
42
|
+
p += re.escape(c)
|
|
43
|
+
|
|
44
|
+
return re.compile(p)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# MIT License, from https://github.com/leetreveil/gengzip
|
|
2
|
+
import struct
|
|
3
|
+
import time
|
|
4
|
+
import zlib
|
|
5
|
+
from builtins import int
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def write32u(value):
|
|
9
|
+
return struct.pack('<L', value)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_gzip_header():
|
|
13
|
+
header = b''
|
|
14
|
+
header += b'\037\213' # magic header (0x1f, 0x8b)
|
|
15
|
+
header += b'\010' # compression method (deflate)
|
|
16
|
+
header += b'\000' # flags (not set)
|
|
17
|
+
header += write32u(int(time.time())) # file modification time
|
|
18
|
+
header += b'\002' # extra flags (maximum compression)
|
|
19
|
+
header += b'\377' # os type (unknown)
|
|
20
|
+
return header
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_gzip_footer(crc, size):
|
|
24
|
+
footer = b''
|
|
25
|
+
footer += write32u(crc)
|
|
26
|
+
footer += write32u(size & 0xffffffff)
|
|
27
|
+
return footer
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compress(d, compresslevel=6):
|
|
31
|
+
"""
|
|
32
|
+
Takes a generator 'd' that provides streaem of data, then returns a wrapper generator
|
|
33
|
+
that yields compressed gzip data stream
|
|
34
|
+
"""
|
|
35
|
+
crc = zlib.crc32(b'') & 0xffffffff
|
|
36
|
+
size = 0
|
|
37
|
+
yield write_gzip_header()
|
|
38
|
+
compress = zlib.compressobj(
|
|
39
|
+
compresslevel, zlib.DEFLATED, -zlib.MAX_WBITS, zlib.DEF_MEM_LEVEL, 0)
|
|
40
|
+
for data in d:
|
|
41
|
+
crc = zlib.crc32(data, crc) & 0xffffffff
|
|
42
|
+
size += len(data)
|
|
43
|
+
compressed = compress.compress(data)
|
|
44
|
+
if len(compressed) > 0:
|
|
45
|
+
yield compressed
|
|
46
|
+
yield compress.flush() + write_gzip_footer(crc, size)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
from typing import IO, BinaryIO, Dict, Tuple, Union
|
|
6
|
+
|
|
7
|
+
from requests import Session
|
|
8
|
+
from requests.adapters import HTTPAdapter
|
|
9
|
+
from requests.packages.urllib3.util.retry import Retry # type: ignore
|
|
10
|
+
|
|
11
|
+
from smart_tests.version import __version__
|
|
12
|
+
|
|
13
|
+
from ..app import Application
|
|
14
|
+
from .authentication import authentication_headers
|
|
15
|
+
from .env_keys import BASE_URL_KEY, SKIP_TIMEOUT_RETRY
|
|
16
|
+
from .gzipgen import compress as gzipgen_compress
|
|
17
|
+
from .logger import Logger
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.mercury.launchableinc.com"
|
|
20
|
+
|
|
21
|
+
# (connect timeout, read timeout)
|
|
22
|
+
DEFAULT_TIMEOUT: Tuple[int, int] = (5, 60)
|
|
23
|
+
DEFAULT_GET_TIMEOUT: Tuple[int, int] = (5, 15)
|
|
24
|
+
|
|
25
|
+
MAX_RETRIES = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_base_url():
|
|
29
|
+
return os.getenv(BASE_URL_KEY) or DEFAULT_BASE_URL
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DryRunResponse:
|
|
33
|
+
def __init__(self, status_code, payload):
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.payload = payload
|
|
36
|
+
|
|
37
|
+
def raise_for_status(self):
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
def json(self):
|
|
41
|
+
return self.payload
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _HttpClient:
|
|
45
|
+
def __init__(self, base_url: str = "", session: Session | None = None, app: Application | None = None):
|
|
46
|
+
self.base_url = base_url or get_base_url()
|
|
47
|
+
self.dry_run = bool(app and app.dry_run)
|
|
48
|
+
self.skip_cert_verification = bool(app and app.skip_cert_verification)
|
|
49
|
+
|
|
50
|
+
if session is None:
|
|
51
|
+
read = MAX_RETRIES
|
|
52
|
+
if os.getenv(SKIP_TIMEOUT_RETRY):
|
|
53
|
+
read = 0
|
|
54
|
+
strategy = Retry(
|
|
55
|
+
total=MAX_RETRIES,
|
|
56
|
+
read=read,
|
|
57
|
+
allowed_methods=["GET", "PUT", "PATCH", "DELETE"],
|
|
58
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
59
|
+
backoff_factor=2
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
adapter = HTTPAdapter(max_retries=strategy)
|
|
63
|
+
s = Session()
|
|
64
|
+
s.mount("http://", adapter)
|
|
65
|
+
s.mount("https://", adapter)
|
|
66
|
+
self.session = s
|
|
67
|
+
else:
|
|
68
|
+
self.session = session
|
|
69
|
+
|
|
70
|
+
self.test_runner = app.test_runner if app else None
|
|
71
|
+
|
|
72
|
+
def request(
|
|
73
|
+
self,
|
|
74
|
+
method: str,
|
|
75
|
+
path: str,
|
|
76
|
+
payload: Union[Dict, BinaryIO] | None = None,
|
|
77
|
+
params: Dict | None = None,
|
|
78
|
+
timeout: Tuple[int, int] = DEFAULT_TIMEOUT,
|
|
79
|
+
compress: bool = False,
|
|
80
|
+
additional_headers: Dict | None = None,
|
|
81
|
+
):
|
|
82
|
+
url = _join_paths(self.base_url, path)
|
|
83
|
+
|
|
84
|
+
if (timeout == DEFAULT_TIMEOUT and method.upper() == "GET"):
|
|
85
|
+
timeout = DEFAULT_GET_TIMEOUT
|
|
86
|
+
|
|
87
|
+
headers = self._headers(compress)
|
|
88
|
+
if additional_headers:
|
|
89
|
+
headers = {**headers, **additional_headers}
|
|
90
|
+
|
|
91
|
+
dry_run_prefix = "(DRY RUN) " if self.dry_run else ""
|
|
92
|
+
Logger().audit(f"{dry_run_prefix}send request method:{method} path:{url} headers:{headers} args:{payload}")
|
|
93
|
+
|
|
94
|
+
if self.dry_run and method.upper() not in ["HEAD", "GET"]:
|
|
95
|
+
return DryRunResponse(status_code=200, payload={
|
|
96
|
+
"id": "dry-run-session", # `record session` use this
|
|
97
|
+
"testPaths": [], # `split_subset` use this
|
|
98
|
+
"rest": [], # `split_subset` use this
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
data = _build_data(payload, compress)
|
|
102
|
+
|
|
103
|
+
# the 'data' argument accepts generator. whenever we can potentially send a large amount of data,
|
|
104
|
+
# we want to use generator to stream data
|
|
105
|
+
response = self.session.request(method, url, headers=headers, timeout=timeout, data=data,
|
|
106
|
+
params=params, verify=(not self.skip_cert_verification))
|
|
107
|
+
Logger().debug(
|
|
108
|
+
f"received response status:{response.status_code} message:{response.reason} headers:{response.headers}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# because (I believe, though I could be wrong) HTTP/2 got rid of status message, our server side HTTP stack
|
|
112
|
+
# doesn't let us forward the status message (=response.reason), which would have been otherwise a very handy
|
|
113
|
+
# mechanism to reliably forward error messages. So instead, we forward JSON error response of the form
|
|
114
|
+
# {"reason": ...}. Backfill response.reason with this JSON error message if it exists, so that the exceptions
|
|
115
|
+
# thrown from response.raise_for_status() will have a meaningful message.
|
|
116
|
+
if response.status_code >= 400 and response.headers.get("Content-Type", "").startswith("application/json"):
|
|
117
|
+
try:
|
|
118
|
+
response.reason = response.json().get("reason", response.reason)
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
return response
|
|
123
|
+
|
|
124
|
+
def _headers(self, compress):
|
|
125
|
+
h = {
|
|
126
|
+
"User-Agent": f"Launchable/{__version__} (Python {platform.python_version()}, {platform.platform()})",
|
|
127
|
+
"Content-Type": "application/json"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if compress:
|
|
131
|
+
h["Content-Encoding"] = "gzip"
|
|
132
|
+
|
|
133
|
+
if self.test_runner:
|
|
134
|
+
h["User-Agent"] = h["User-Agent"] + f" TestRunner/{self.test_runner}"
|
|
135
|
+
|
|
136
|
+
return {**h, **authentication_headers()}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _file_to_generator(f: IO, chunk_size=4096):
|
|
140
|
+
"""
|
|
141
|
+
Returns a generator that reads from a given file-like object
|
|
142
|
+
"""
|
|
143
|
+
while True:
|
|
144
|
+
data = f.read(chunk_size)
|
|
145
|
+
if not data:
|
|
146
|
+
break
|
|
147
|
+
yield data
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _build_data(payload: Union[BinaryIO, Dict] | None, compress: bool):
|
|
151
|
+
if payload is None:
|
|
152
|
+
return None
|
|
153
|
+
if isinstance(payload, dict):
|
|
154
|
+
encoded = json.dumps(payload).encode()
|
|
155
|
+
if compress:
|
|
156
|
+
return gzip.compress(encoded)
|
|
157
|
+
else:
|
|
158
|
+
return encoded
|
|
159
|
+
else:
|
|
160
|
+
# payload is BinaryIO
|
|
161
|
+
if compress:
|
|
162
|
+
# this produces a generator
|
|
163
|
+
return gzipgen_compress(_file_to_generator(payload))
|
|
164
|
+
else:
|
|
165
|
+
return payload
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _join_paths(*components):
|
|
169
|
+
return '/'.join([c.strip('/') for c in components])
|