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,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,7 @@
1
+ from enum import Enum
2
+
3
+
4
+ class CIProvider(Enum):
5
+ JENKINS = "jenkins"
6
+ GITHUB_ACTIONS = "github-actions"
7
+ CIRCLECI = "circleci"
@@ -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,4 @@
1
+ import re
2
+
3
+ # find *Test, *Tests, *TestCase, *Spec + .java, .scala, .kt, .groovy
4
+ jvm_test_pattern = re.compile(r'^.*(?:Test(?:Case|s)?|Spec)\.(?:java|scala|kt|groovy)$')
@@ -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])