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,125 @@
1
+ from datetime import datetime
2
+ from typing import Annotated, List
3
+ from xml.etree import ElementTree as ET
4
+
5
+ from junitparser import JUnitXml # type: ignore
6
+
7
+ import smart_tests.args4p.typer as typer
8
+
9
+ from ..commands.record.tests import RecordTests
10
+ from ..commands.subset import Subset
11
+ from ..testpath import TestPath
12
+ from . import smart_tests
13
+
14
+
15
+ def parse_func(p: str) -> ET.ElementTree:
16
+
17
+ def parse_suite(suite: ET.Element):
18
+ DATETIME_FORMAT = '%Y%m%d %H:%M:%S.%f'
19
+
20
+ suite_name = suite.get('name')
21
+ for test in suite.iter("test"):
22
+ test_name = test.get('name')
23
+
24
+ status_node = test.find('status')
25
+ status = status_node.get(
26
+ 'status') if status_node is not None else None
27
+
28
+ nested_status_node = test.find('./kw/status')
29
+ nested_status = nested_status_node.get('status') if nested_status_node is not None else None
30
+
31
+ if status is not None:
32
+ start_time_str = status_node.get('starttime') if status_node is not None else ''
33
+ end_time_str = status_node.get('endtime') if status_node is not None else ''
34
+
35
+ if start_time_str != '' and end_time_str != '':
36
+ start_time = datetime.strptime(str(start_time_str), DATETIME_FORMAT)
37
+ end_time = datetime.strptime(str(end_time_str), DATETIME_FORMAT)
38
+
39
+ duration = end_time - start_time
40
+
41
+ testcase = ET.SubElement(testsuite, "testcase", {
42
+ "name": str(test_name),
43
+ "classname": str(suite_name),
44
+ "time": str(duration.total_seconds()) if duration is not None else '0',
45
+ })
46
+
47
+ if status == "FAIL":
48
+ failure = ET.SubElement(testcase, 'failure')
49
+
50
+ msg = test.find('kw/msg')
51
+ failure.text = msg.text if msg is not None else ''
52
+ if status == "NOT_RUN" or nested_status == 'NOT_RUN':
53
+ skipped = ET.SubElement(testcase, "skipped") # noqa: F841
54
+
55
+ original_tree = ET.parse(p)
56
+ testsuite = ET.Element("testsuite", {"name": "robot"})
57
+
58
+ SUITE_TAG_NAME = "suite"
59
+ for suites in original_tree.findall(SUITE_TAG_NAME):
60
+ nested_suites = suites.findall(SUITE_TAG_NAME)
61
+
62
+ if nested_suites:
63
+ # Run tests in a directory
64
+ for suite in nested_suites:
65
+ parse_suite(suite)
66
+ else:
67
+ # Run tests in a single file
68
+ parse_suite(suites)
69
+
70
+ return ET.ElementTree(testsuite)
71
+
72
+
73
+ @smart_tests.record.tests
74
+ def record_tests(
75
+ client: RecordTests,
76
+ reports: Annotated[List[str], typer.Argument(
77
+ multiple=True,
78
+ help="Test report files to process"
79
+ )],
80
+ ):
81
+ for r in reports:
82
+ client.report(r)
83
+
84
+ client.junitxml_parse_func = parse_func
85
+ client.run()
86
+
87
+
88
+ @smart_tests.subset
89
+ def subset(
90
+ client: Subset,
91
+ reports: Annotated[List[str], typer.Argument(
92
+ multiple=True,
93
+ required=False,
94
+ help="Test report files to process"
95
+ )] = [],
96
+ ):
97
+ for r in reports:
98
+ xml = JUnitXml.fromfile(r, parse_func)
99
+
100
+ for suite in xml:
101
+ for case in suite:
102
+ cls_name = case._elem.attrib.get("classname")
103
+ name = case._elem.attrib.get('name')
104
+ if cls_name != '' and name != '':
105
+ client.test_path([{'type': 'class', 'name': cls_name}, {'type': 'testcase', 'name': name}])
106
+
107
+ client.formatter = robot_formatter
108
+ client.separator = " "
109
+ client.run()
110
+
111
+
112
+ def robot_formatter(x: TestPath):
113
+ cls_name = ''
114
+ case = ''
115
+ for path in x:
116
+ t = path['type']
117
+ if t == 'class':
118
+ cls_name = path['name']
119
+ if t == 'testcase':
120
+ case = path['name']
121
+
122
+ if cls_name != '' and case != '':
123
+ return f"-s '{cls_name}' -t '{case}'"
124
+
125
+ return ''
@@ -0,0 +1,5 @@
1
+ from . import smart_tests
2
+
3
+ subset = smart_tests.CommonSubsetImpls(__name__).scan_files('*_spec.rb')
4
+ record_tests = smart_tests.CommonRecordTestImpls(__name__).report_files()
5
+ detect_flakes = smart_tests.CommonDetectFlakesImpls(__name__).detect_flakes()
@@ -0,0 +1,235 @@
1
+ import glob
2
+ import os
3
+ import sys
4
+ import types
5
+ from typing import Annotated
6
+
7
+ import click
8
+ from junitparser import TestCase, TestSuite # type: ignore
9
+
10
+ import smart_tests.args4p.typer as typer
11
+ from smart_tests import args4p
12
+ from smart_tests.args4p import decorator
13
+ from smart_tests.args4p.command import Group
14
+ from smart_tests.commands.detect_flakes import DetectFlakes
15
+ from smart_tests.commands.detect_flakes import detect_flakes as detect_flakes_cmd
16
+ from smart_tests.commands.record.tests import RecordTests
17
+ from smart_tests.commands.record.tests import tests as record_tests_cmd
18
+ from smart_tests.commands.subset import Subset
19
+ from smart_tests.commands.subset import subset as subset_cmd
20
+
21
+ from ..testpath import TestPath
22
+
23
+
24
+ def cmdname(m):
25
+ """figure out the sub-command name from a test runner function"""
26
+
27
+ # a.b.cde -> cde
28
+ # xyz -> xyz
29
+ #
30
+ # In python module name the conventional separator is '_' but in command name,
31
+ # it is '-', so we do replace that
32
+ return m[m.rfind('.') + 1:].replace('_', '-')
33
+
34
+
35
+ def wrap(f, group: Group, name=None):
36
+ """
37
+ Wraps a 'plugin' function into a click command and registers it to the given group.
38
+
39
+ a plugin function receives the scanner object in its first argument
40
+ """
41
+ if not name:
42
+ name = cmdname(f.__module__)
43
+ d = args4p.command(name=name)
44
+ cmd = d(f)
45
+ group.add_command(cmd)
46
+ return cmd
47
+
48
+
49
+ @decorator
50
+ def subset(f):
51
+ return wrap(f, subset_cmd)
52
+
53
+
54
+ record = types.SimpleNamespace()
55
+ # this is also meant to be used as a decorator, e.g., @record.tests
56
+ record.tests = lambda f: wrap(f, record_tests_cmd)
57
+
58
+
59
+ @decorator
60
+ def flake_detection(f):
61
+ return wrap(f, detect_flakes_cmd)
62
+
63
+
64
+ # TODO
65
+ # @decorator
66
+ # def split_subset(f):
67
+ # return wrap(f, split_subset_cmd)
68
+
69
+
70
+ class CommonSubsetImpls:
71
+ """
72
+ Typical 'subset' implementations that are reusable.
73
+ """
74
+
75
+ def __init__(self, module_name):
76
+ self.cmdname = cmdname(module_name)
77
+
78
+ def scan_files(self, pattern):
79
+ """
80
+ Suitable for test runners that use files as unit of tests where file names follow a naming pattern.
81
+
82
+ :param pattern: file masks that identify test files, such as '*_spec.rb'
83
+ """
84
+ def subset(
85
+ client: Subset,
86
+ files: Annotated[list[str], typer.Argument(
87
+ multiple=True,
88
+ required=False,
89
+ help="Test files or directories to include in the subset"
90
+ )] = []
91
+ ):
92
+ # client type: Optimize in def lauchable.commands.subset.subset
93
+ def parse(fname: str):
94
+ if os.path.isdir(fname):
95
+ client.scan(fname, '**/' + pattern)
96
+ elif fname == '@-':
97
+ # read stdin
98
+ for line in sys.stdin:
99
+ parse(line.rstrip())
100
+ elif fname.startswith('@'):
101
+ # read response file
102
+ with open(fname[1:]) as f:
103
+ for line in f:
104
+ parse(line.rstrip())
105
+ else:
106
+ # assume it's a file
107
+ client.test_path(fname)
108
+
109
+ for f in files:
110
+ parse(f)
111
+
112
+ client.run()
113
+
114
+ return wrap(subset, subset_cmd, self.cmdname)
115
+
116
+
117
+ class CommonRecordTestImpls:
118
+ """
119
+ Typical 'record tests' implementations that are reusable.
120
+ """
121
+
122
+ def __init__(self, module_name):
123
+ self.cmdname = cmdname(module_name)
124
+
125
+ def report_files(self, file_mask="*.xml"):
126
+ """
127
+ Suitable for test runners that create a directory full of JUnit report files.
128
+
129
+ 'record tests' expect JUnit report/XML file names.
130
+ """
131
+
132
+ def record_tests(
133
+ client: RecordTests,
134
+ source_roots: Annotated[list[str], typer.Argument(
135
+ multiple=True,
136
+ help="Source directories containing test report files"
137
+ )]
138
+ ):
139
+ CommonRecordTestImpls.load_report_files(client=client, source_roots=source_roots, file_mask=file_mask)
140
+
141
+ return wrap(record_tests, record_tests_cmd, self.cmdname)
142
+
143
+ def file_profile_report_files(self):
144
+ """
145
+ Suitable for test runners that create a directory full of JUnit report files.
146
+
147
+ 'record tests' expect JUnit report/XML file names.
148
+ """
149
+
150
+ def record_tests(client: RecordTests,
151
+ source_roots: Annotated[list[str], typer.Argument(
152
+ multiple=True,
153
+ help="Source directories containing test report files"
154
+ )]):
155
+ def path_builder(
156
+ case: TestCase, suite: TestSuite, report_file: str
157
+ ) -> TestPath:
158
+ def find_filename():
159
+ """look for what looks like file names from test reports"""
160
+ for e in [case, suite]:
161
+ for a in ["file", "filepath"]:
162
+ filepath = e._elem.attrib.get(a)
163
+ if filepath:
164
+ return filepath
165
+ return None # failing to find a test name
166
+
167
+ filepath = find_filename()
168
+ if not filepath:
169
+ raise click.ClickException("No file name found in %s" % report_file)
170
+
171
+ # default test path in `subset` expects to have this file name
172
+ test_path = [client.make_file_path_component(filepath)]
173
+ if suite.name:
174
+ test_path.append({"type": "testsuite", "name": suite.name})
175
+ if case.name:
176
+ test_path.append({"type": "testcase", "name": case.name})
177
+ return test_path
178
+
179
+ client.path_builder = path_builder
180
+
181
+ for r in source_roots:
182
+ client.report(r)
183
+ client.run()
184
+
185
+ return wrap(record_tests, record_tests_cmd, self.cmdname)
186
+
187
+ @classmethod
188
+ def load_report_files(cls, client: RecordTests, source_roots, file_mask="*.xml"):
189
+ # client type: RecordTests in def launchable.commands.record.tests.tests
190
+ # Accept both file names and GLOB patterns
191
+ # Simple globs like '*.xml' can be dealt with by shell, but
192
+ # not all shells consistently deal with advanced GLOBS like '**'
193
+ # so it's worth supporting it here.
194
+ for root in source_roots:
195
+ match = False
196
+ for t in glob.iglob(root, recursive=True):
197
+ match = True
198
+ if os.path.isdir(t):
199
+ client.scan(t, file_mask)
200
+ else:
201
+ client.report(t)
202
+
203
+ if not match:
204
+ # By following the shell convention, if the file doesn't exist or GLOB doesn't match anything,
205
+ # raise it as an error. Note this can happen for reasons other than a configuration error.
206
+ # For example, if a build catastrophically failed and no
207
+ # tests got run.
208
+ click.echo(f"No matches found: {root}", err=True)
209
+ # intentionally exiting with zero
210
+ return
211
+
212
+ client.run()
213
+
214
+
215
+ class CommonDetectFlakesImpls:
216
+ def __init__(
217
+ self,
218
+ module_name,
219
+ formatter=None,
220
+ separator="\n",
221
+ ):
222
+ self.cmdname = cmdname(module_name)
223
+ self._formatter = formatter
224
+ self._separator = separator
225
+
226
+ def detect_flakes(self):
227
+ def detect_flakes(client: DetectFlakes):
228
+ if self._formatter:
229
+ client.formatter = self._formatter
230
+ if self._separator:
231
+ client.separator = self._separator
232
+
233
+ client.run()
234
+
235
+ return wrap(detect_flakes, detect_flakes_cmd, self.cmdname)
@@ -0,0 +1,49 @@
1
+ import xml.etree.ElementTree as ET
2
+ from typing import Annotated, List, cast
3
+
4
+ import smart_tests.args4p.typer as typer
5
+
6
+ from ..commands.record.tests import RecordTests
7
+ from ..commands.subset import Subset
8
+ from . import smart_tests
9
+
10
+
11
+ @smart_tests.record.tests
12
+ def record_tests(
13
+ client: RecordTests,
14
+ reports: Annotated[List[str], typer.Argument(
15
+ multiple=True,
16
+ help="Test report files to process"
17
+ )],
18
+ ):
19
+ def parse_func(report: str) -> ET.ElementTree:
20
+ """
21
+ Vitest junit report doesn't set file/filepath attributes on test cases, and it's set as a classname attribute instead.
22
+ So, set the classname value as the file name in this function.
23
+ e.g.) <testcase classname="src/components/Hello.test.tsx" name="renders hello message" time="0.008676833">
24
+ """
25
+ tree = cast(ET.ElementTree, ET.parse(report))
26
+ root = tree.getroot()
27
+
28
+ if root is None:
29
+ return tree
30
+
31
+ for test_suite in root.findall('testsuite'):
32
+ for test_case in test_suite.findall('testcase'):
33
+ classname = test_case.get('classname', '')
34
+ test_case.set('file', classname)
35
+ test_case.attrib.pop('classname', None)
36
+
37
+ return tree
38
+
39
+ client.junitxml_parse_func = parse_func
40
+ smart_tests.CommonRecordTestImpls.load_report_files(client=client, source_roots=reports)
41
+
42
+
43
+ @smart_tests.subset
44
+ def subset(client: Subset):
45
+ # read lines as test file names
46
+ for t in client.stdin():
47
+ client.test_path(t.rstrip("\n"))
48
+
49
+ client.run()
@@ -0,0 +1,79 @@
1
+ import html
2
+ import xml.etree.ElementTree as ET # type: ignore
3
+ from typing import Annotated, List, cast
4
+
5
+ import click
6
+ from junitparser import TestCase, TestSuite
7
+
8
+ import smart_tests.args4p.typer as typer
9
+
10
+ from ..commands.record.tests import RecordTests
11
+ from ..commands.subset import Subset
12
+ from ..testpath import TestPath
13
+ from . import smart_tests
14
+
15
+
16
+ @smart_tests.record.tests
17
+ def record_tests(
18
+ client: RecordTests,
19
+ reports: Annotated[List[str], typer.Argument(
20
+ multiple=True,
21
+ help="Test report files to process"
22
+ )],
23
+ ):
24
+
25
+ def parse_func(p: str) -> ET.ElementTree:
26
+ tree = cast(ET.ElementTree, ET.parse(p))
27
+ root = tree.getroot()
28
+ if root is None:
29
+ return tree
30
+
31
+ for testsuite in root.findall('testsuite'):
32
+ for testcase in testsuite.findall('testcase'):
33
+ failure = testcase.find('failure')
34
+ if failure is not None:
35
+ # Note(Konboi): XCTest escape `"` to `&quot;` in the message, so save as unescaped text
36
+ message = html.unescape(failure.get('message', ''))
37
+ body = failure.text or ''
38
+ failure.text = f"{message}\n{body}"
39
+
40
+ return tree
41
+
42
+ def path_builder(case: TestCase, suite: TestSuite, report_path: str) -> TestPath:
43
+ class_attr = case.classname
44
+ [target, *rest] = class_attr.split('.')
45
+ class_name = '/'.join(rest)
46
+ return [
47
+ {"type": "target", "name": target},
48
+ {"type": "class", "name": class_name},
49
+ {"type": "testcase", "name": case.name},
50
+ ]
51
+
52
+ client.junitxml_parse_func = parse_func
53
+ client.path_builder = path_builder
54
+ smart_tests.CommonRecordTestImpls.load_report_files(client=client, source_roots=reports)
55
+
56
+
57
+ @smart_tests.subset
58
+ def subset(client: Subset):
59
+ if not client.is_get_tests_from_previous_sessions or not client.is_output_exclusion_rules:
60
+ click.secho(
61
+ "XCTest profile only supports the subset with `--get-tests-from-previous-sessions` and `--output-exclusion-rules` options", # noqa: E501
62
+ fg='red',
63
+ err=True,
64
+ )
65
+
66
+ def formatter(test_path: TestPath) -> str:
67
+ if len(test_path) == 0:
68
+ return ""
69
+
70
+ # only target case
71
+ if len(test_path) == 1:
72
+ return f"-skip-testing:{test_path[0]['name']}"
73
+
74
+ # default target/class format
75
+ return f"-skip-testing:{test_path[0]['name']}/{test_path[1]['name']}"
76
+
77
+ client.formatter = formatter
78
+ client.separator = "\n"
79
+ client.run()
@@ -0,0 +1,154 @@
1
+ import os
2
+ import pathlib
3
+ import subprocess
4
+ import urllib
5
+
6
+ # No additional typing imports needed
7
+
8
+ # Path component is a node in a tree.
9
+ # It's the equivalent of a short file/directory name in a file system.
10
+ # In our abstraction, it's represented as arbitrary bag of attributes
11
+ TestPathComponent = dict[str, str]
12
+
13
+ # TestPath is a full path to a node in a tree from the root
14
+ # It's the equivalent of an absolute file name in a file system
15
+ TestPath = list[TestPathComponent]
16
+
17
+
18
+ def parse_test_path(tp_str: str) -> TestPath:
19
+ """Parse a string representation of TestPath."""
20
+ if tp_str == '':
21
+ return []
22
+ ret: TestPath = []
23
+ for component_str in tp_str.split('#'):
24
+ if component_str == '&':
25
+ # Technically, this should be mapped to {None:None}. But because the
26
+ # TestPath definition is now dict[str, str], not dict[str | None,
27
+ # Optinal[str]], we cannot add it. Fixing this definition needs to
28
+ # fix callers not to assume they are always str. In practice, this
29
+ # is a rare case. Do not appent {None: None} now...
30
+ # ret.append({None: None})
31
+ continue
32
+ first = True
33
+ component = {}
34
+ for kv in component_str.split('&'):
35
+ if first:
36
+ first = False
37
+ if kv:
38
+ (component['type'], component['name']) = _parse_kv(kv)
39
+ else:
40
+ (k, v) = _parse_kv(kv)
41
+ component[k] = v
42
+ ret.append(component)
43
+ return ret
44
+
45
+
46
+ def _parse_kv(kv: str) -> tuple[str, str]:
47
+ kvs = kv.split('=')
48
+ if len(kvs) != 2:
49
+ raise ValueError('Malformed TestPath component: ' + kv)
50
+ return (_decode_str(kvs[0]), _decode_str(kvs[1]))
51
+
52
+
53
+ def unparse_test_path(tp: TestPath) -> str:
54
+ """Create a string representation of TestPath."""
55
+ ret = []
56
+ for component in tp:
57
+ s = ''
58
+ pairs = []
59
+ if component.get('type', None) and component.get('name', None):
60
+ s += _encode_str(component['type']) + '=' + _encode_str(component['name'])
61
+ for k, v in component.items():
62
+ if k not in ('type', 'name'):
63
+ pairs.append((k, v))
64
+ else:
65
+ for k, v in component.items():
66
+ if not k or not v:
67
+ continue
68
+ pairs.append((k, v))
69
+ if len(pairs) == 0:
70
+ s = '&'
71
+ pairs = sorted(pairs, key=lambda p: p[0])
72
+ for (k, v) in pairs:
73
+ s += '&'
74
+ s += _encode_str(k) + '=' + _encode_str(v)
75
+ ret.append(s)
76
+ return '#'.join(ret)
77
+
78
+
79
+ def _decode_str(s: str) -> str:
80
+ return urllib.parse.unquote(s)
81
+
82
+
83
+ def _encode_str(s: str) -> str:
84
+ return s.replace('%', '%25').replace('=', '%3D').replace('#', '%23').replace('&', '%26')
85
+
86
+
87
+ def _relative_to(p: pathlib.Path, base: str) -> pathlib.Path:
88
+ return p.resolve(strict=False).relative_to(base)
89
+
90
+
91
+ class FilePathNormalizer:
92
+ """Normalize file paths based on the Git repository root
93
+
94
+ Some test runners output absolute file paths. This is not preferrable when
95
+ making statistical data on tests as the absolute paths can vary per machine
96
+ or per run. FilePathNormalizer guesses the relative paths based on the Git
97
+ repository root.
98
+ """
99
+
100
+ def __init__(self, base_path: str | None = None, no_base_path_inference: bool = False):
101
+ self._base_path = base_path
102
+ self._no_base_path_inference = no_base_path_inference
103
+ self._inferred_base_path = None # type: str | None
104
+
105
+ def relativize(self, p: str) -> str:
106
+ return str(self._relativize(pathlib.Path(os.path.normpath(p))))
107
+
108
+ def _relativize(self, p: pathlib.Path) -> pathlib.Path:
109
+ if not p.is_absolute():
110
+ return p
111
+
112
+ if self._base_path:
113
+ return _relative_to(p, self._base_path)
114
+
115
+ if self._no_base_path_inference:
116
+ return p
117
+
118
+ if not self._inferred_base_path:
119
+ self._inferred_base_path = self._auto_infer_base_path(p)
120
+
121
+ if self._inferred_base_path:
122
+ return _relative_to(p, self._inferred_base_path)
123
+
124
+ return p
125
+
126
+ def get_effective_base_path(self) -> str | None:
127
+ """Get the effective base path, either explicitly set or inferred."""
128
+ if self._base_path:
129
+ return self._base_path
130
+ return self._inferred_base_path
131
+
132
+ def _auto_infer_base_path(self, p: pathlib.Path) -> str | None:
133
+ # If p is a file, start from its parent directory
134
+ if p.is_file():
135
+ p = p.parent
136
+
137
+ while p != p.root and not p.exists():
138
+ p = p.parent
139
+ try:
140
+ toplevel = subprocess.check_output(
141
+ ['git', 'rev-parse', '--show-superproject-working-tree'],
142
+ cwd=str(p),
143
+ stderr=subprocess.DEVNULL,
144
+ universal_newlines=True).strip()
145
+ if toplevel:
146
+ return toplevel
147
+ return subprocess.check_output(
148
+ ['git', 'rev-parse', '--show-toplevel'],
149
+ cwd=str(p),
150
+ stderr=subprocess.DEVNULL,
151
+ universal_newlines=True).strip()
152
+ except subprocess.CalledProcessError:
153
+ # Cannot infer the Git repo. Continue with the abs path...
154
+ return None
File without changes