atlas-init 0.4.4__py3-none-any.whl → 0.6.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.
- atlas_init/__init__.py +1 -1
- atlas_init/cli.py +2 -0
- atlas_init/cli_cfn/app.py +3 -4
- atlas_init/cli_cfn/cfn_parameter_finder.py +61 -53
- atlas_init/cli_cfn/contract.py +4 -7
- atlas_init/cli_cfn/example.py +8 -18
- atlas_init/cli_helper/go.py +7 -11
- atlas_init/cli_root/mms_released.py +46 -0
- atlas_init/cli_root/trigger.py +6 -6
- atlas_init/cli_tf/app.py +3 -84
- atlas_init/cli_tf/ci_tests.py +493 -0
- atlas_init/cli_tf/codegen/__init__.py +0 -0
- atlas_init/cli_tf/codegen/models.py +97 -0
- atlas_init/cli_tf/codegen/openapi_minimal.py +74 -0
- atlas_init/cli_tf/github_logs.py +7 -94
- atlas_init/cli_tf/go_test_run.py +385 -132
- atlas_init/cli_tf/go_test_summary.py +331 -4
- atlas_init/cli_tf/go_test_tf_error.py +380 -0
- atlas_init/cli_tf/hcl/modifier.py +14 -12
- atlas_init/cli_tf/hcl/modifier2.py +87 -0
- atlas_init/cli_tf/mock_tf_log.py +1 -1
- atlas_init/cli_tf/{schema_v2_api_parsing.py → openapi.py} +95 -17
- atlas_init/cli_tf/schema_v2.py +43 -1
- atlas_init/crud/__init__.py +0 -0
- atlas_init/crud/mongo_client.py +115 -0
- atlas_init/crud/mongo_dao.py +296 -0
- atlas_init/crud/mongo_utils.py +239 -0
- atlas_init/repos/go_sdk.py +12 -3
- atlas_init/repos/path.py +110 -7
- atlas_init/settings/config.py +3 -6
- atlas_init/settings/env_vars.py +22 -31
- atlas_init/settings/interactive2.py +134 -0
- atlas_init/tf/.terraform.lock.hcl +59 -59
- atlas_init/tf/always.tf +5 -5
- atlas_init/tf/main.tf +3 -3
- atlas_init/tf/modules/aws_kms/aws_kms.tf +1 -1
- atlas_init/tf/modules/aws_s3/provider.tf +2 -1
- atlas_init/tf/modules/aws_vpc/provider.tf +2 -1
- atlas_init/tf/modules/cfn/cfn.tf +0 -8
- atlas_init/tf/modules/cfn/kms.tf +5 -5
- atlas_init/tf/modules/cfn/provider.tf +7 -0
- atlas_init/tf/modules/cfn/variables.tf +1 -1
- atlas_init/tf/modules/cloud_provider/cloud_provider.tf +1 -1
- atlas_init/tf/modules/cloud_provider/provider.tf +2 -1
- atlas_init/tf/modules/cluster/cluster.tf +31 -31
- atlas_init/tf/modules/cluster/provider.tf +2 -1
- atlas_init/tf/modules/encryption_at_rest/provider.tf +2 -1
- atlas_init/tf/modules/federated_vars/federated_vars.tf +1 -1
- atlas_init/tf/modules/federated_vars/provider.tf +2 -1
- atlas_init/tf/modules/project_extra/project_extra.tf +1 -10
- atlas_init/tf/modules/project_extra/provider.tf +8 -0
- atlas_init/tf/modules/stream_instance/provider.tf +8 -0
- atlas_init/tf/modules/stream_instance/stream_instance.tf +0 -9
- atlas_init/tf/modules/vpc_peering/provider.tf +10 -0
- atlas_init/tf/modules/vpc_peering/vpc_peering.tf +0 -10
- atlas_init/tf/modules/vpc_privatelink/versions.tf +2 -1
- atlas_init/tf/outputs.tf +1 -0
- atlas_init/tf/providers.tf +1 -1
- atlas_init/tf/variables.tf +7 -7
- atlas_init/typer_app.py +4 -8
- {atlas_init-0.4.4.dist-info → atlas_init-0.6.0.dist-info}/METADATA +7 -4
- atlas_init-0.6.0.dist-info/RECORD +121 -0
- atlas_init-0.4.4.dist-info/RECORD +0 -105
- {atlas_init-0.4.4.dist-info → atlas_init-0.6.0.dist-info}/WHEEL +0 -0
- {atlas_init-0.4.4.dist-info → atlas_init-0.6.0.dist-info}/entry_points.txt +0 -0
- {atlas_init-0.4.4.dist-info → atlas_init-0.6.0.dist-info}/licenses/LICENSE +0 -0
atlas_init/cli_tf/go_test_run.py
CHANGED
@@ -1,40 +1,48 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
from collections import defaultdict, deque
|
4
|
+
from functools import total_ordering
|
3
5
|
import logging
|
4
6
|
import re
|
5
|
-
from collections.abc import
|
7
|
+
from collections.abc import Callable
|
8
|
+
from dataclasses import dataclass, field
|
9
|
+
from datetime import datetime
|
6
10
|
from enum import StrEnum
|
7
|
-
from functools import total_ordering
|
8
11
|
from pathlib import Path
|
12
|
+
from typing import NamedTuple, TypeAlias
|
9
13
|
|
10
14
|
import humanize
|
11
|
-
from
|
12
|
-
from
|
13
|
-
from pydantic import Field, field_validator
|
15
|
+
from model_lib import Entity, utc_datetime, utc_datetime_ms
|
16
|
+
from pydantic import Field, model_validator
|
14
17
|
from zero_3rdparty.datetime_utils import utc_now
|
15
18
|
|
19
|
+
from atlas_init.repos.path import go_package_prefix
|
20
|
+
|
16
21
|
logger = logging.getLogger(__name__)
|
17
22
|
|
18
23
|
|
19
24
|
class GoTestStatus(StrEnum):
|
20
25
|
RUN = "RUN"
|
26
|
+
PAUSE = "PAUSE"
|
27
|
+
NAME = "NAME"
|
21
28
|
PASS = "PASS" # noqa: S105 #nosec
|
22
29
|
FAIL = "FAIL"
|
23
30
|
SKIP = "SKIP"
|
31
|
+
CONT = "CONT"
|
32
|
+
TIMEOUT = "TIMEOUT"
|
33
|
+
PKG_OK = "ok"
|
24
34
|
|
35
|
+
@classmethod
|
36
|
+
def is_running(cls, status: GoTestStatus) -> bool:
|
37
|
+
return status in {cls.RUN, cls.PAUSE, cls.NAME, cls.CONT}
|
25
38
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
# PERFORMANCE_REGRESSION = "PERFORMANCE_REGRESSION"
|
30
|
-
FIRST_TIME_ERROR = "FIRST_TIME_ERROR"
|
31
|
-
LEGIT_ERROR = "LEGIT_ERROR"
|
32
|
-
PANIC = "PANIC"
|
33
|
-
|
39
|
+
@classmethod
|
40
|
+
def is_running_but_not_paused(cls, status: GoTestStatus) -> bool:
|
41
|
+
return status != cls.PAUSE and cls.is_running(status)
|
34
42
|
|
35
|
-
|
36
|
-
|
37
|
-
|
43
|
+
@classmethod
|
44
|
+
def is_pass_or_fail(cls, status: GoTestStatus) -> bool:
|
45
|
+
return status in {cls.PASS, cls.FAIL, cls.TIMEOUT} # TIMEOUT is considered a failure in this context
|
38
46
|
|
39
47
|
|
40
48
|
class GoTestContextStep(Entity):
|
@@ -74,36 +82,68 @@ def extract_group_name(log_path: Path | None) -> str:
|
|
74
82
|
return "_".join(last_part.split("_")[1:]) if "_" in last_part else last_part
|
75
83
|
|
76
84
|
|
85
|
+
def parse_tests(
|
86
|
+
log_lines: list[str],
|
87
|
+
) -> list[GoTestRun]:
|
88
|
+
context = ParseContext()
|
89
|
+
parser = wait_for_relvant_line
|
90
|
+
for line in log_lines:
|
91
|
+
parser = parser(line, context)
|
92
|
+
context.last_lines.append(line)
|
93
|
+
result = context.finish_parsing()
|
94
|
+
return result.tests
|
95
|
+
|
96
|
+
|
97
|
+
class GoTestRuntimeStats(NamedTuple):
|
98
|
+
slowest_seconds: float
|
99
|
+
average_seconds: float | None
|
100
|
+
name_with_package: str
|
101
|
+
runs: list[GoTestRun]
|
102
|
+
|
103
|
+
@property
|
104
|
+
def slowest_duration(self) -> str:
|
105
|
+
return humanize.naturaldelta(self.slowest_seconds)
|
106
|
+
|
107
|
+
@property
|
108
|
+
def average_duration(self) -> str:
|
109
|
+
if self.average_seconds is None:
|
110
|
+
return ""
|
111
|
+
return humanize.naturaldelta(self.average_seconds)
|
112
|
+
|
113
|
+
|
114
|
+
class GoTestLastPassStat(NamedTuple):
|
115
|
+
pass_ts: utc_datetime
|
116
|
+
name_with_package: str
|
117
|
+
pass_when: str
|
118
|
+
last_pass: GoTestRun
|
119
|
+
|
120
|
+
|
77
121
|
@total_ordering
|
78
122
|
class GoTestRun(Entity):
|
79
123
|
name: str
|
80
124
|
status: GoTestStatus = GoTestStatus.RUN
|
81
|
-
|
82
|
-
|
83
|
-
finish_ts:
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
def finish_summary(self) -> str:
|
95
|
-
finish_line = self.finish_line
|
96
|
-
lines = [
|
97
|
-
self.start_line.text if finish_line is None else finish_line.text,
|
98
|
-
self.url,
|
99
|
-
]
|
100
|
-
return "\n".join(lines + self.context_lines)
|
125
|
+
ts: utc_datetime_ms
|
126
|
+
output_lines: list[str] = Field(default_factory=list)
|
127
|
+
finish_ts: utc_datetime_ms | None = None
|
128
|
+
run_seconds: float | None = Field(default=None, init=False)
|
129
|
+
|
130
|
+
package_url: str | None = Field(default=None, init=False)
|
131
|
+
|
132
|
+
log_path: Path | None = Field(default=None, init=False)
|
133
|
+
env: str | None = Field(default=None, init=False)
|
134
|
+
branch: str | None = Field(default=None, init=False)
|
135
|
+
resources: list[str] = Field(default_factory=list, init=False)
|
136
|
+
job_url: str | None = Field(default=None, init=False)
|
101
137
|
|
102
138
|
def __lt__(self, other) -> bool:
|
103
139
|
if not isinstance(other, GoTestRun):
|
104
140
|
raise TypeError
|
105
141
|
return (self.ts, self.name) < (other.ts, other.name)
|
106
142
|
|
143
|
+
@property
|
144
|
+
def id(self) -> str:
|
145
|
+
return f"{self.ts.isoformat()}-{self.name}"
|
146
|
+
|
107
147
|
@property
|
108
148
|
def when(self) -> str:
|
109
149
|
return humanize.naturaltime(self.ts)
|
@@ -115,17 +155,12 @@ class GoTestRun(Entity):
|
|
115
155
|
return "unknown"
|
116
156
|
|
117
157
|
@property
|
118
|
-
def
|
119
|
-
return "\n".join(self.
|
120
|
-
|
121
|
-
@property
|
122
|
-
def url(self) -> str:
|
123
|
-
line = self.finish_line or self.start_line
|
124
|
-
return f"{self.job.html_url}#step:{self.test_step}:{line.number}"
|
158
|
+
def output_lines_str(self) -> str:
|
159
|
+
return "\n".join(self.output_lines)
|
125
160
|
|
126
161
|
@property
|
127
162
|
def is_failure(self) -> bool:
|
128
|
-
return self.status
|
163
|
+
return self.status in {GoTestStatus.FAIL, GoTestStatus.TIMEOUT}
|
129
164
|
|
130
165
|
@property
|
131
166
|
def is_pass(self) -> bool:
|
@@ -135,113 +170,331 @@ class GoTestRun(Entity):
|
|
135
170
|
def group_name(self) -> str:
|
136
171
|
return extract_group_name(self.log_path)
|
137
172
|
|
138
|
-
def
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
173
|
+
def package_rel_path(self, repo_path: Path) -> str:
|
174
|
+
if url := self.package_url:
|
175
|
+
prefix = go_package_prefix(repo_path)
|
176
|
+
return url.removeprefix(prefix).rstrip("/")
|
177
|
+
return ""
|
178
|
+
|
179
|
+
@property
|
180
|
+
def name_with_package(self) -> str:
|
181
|
+
if self.package_url:
|
182
|
+
return f"{self.package_url.split('/')[-1]}/{self.name}"
|
183
|
+
return self.name
|
143
184
|
|
144
185
|
@classmethod
|
145
|
-
def
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
job: WorkflowJob | GoTestContext,
|
151
|
-
test_step_nr: int,
|
152
|
-
) -> GoTestRun:
|
153
|
-
start_line = LineInfo(number=line_number, text=line)
|
154
|
-
return cls(
|
155
|
-
name=match.name,
|
156
|
-
status=match.status,
|
157
|
-
ts=match.ts,
|
158
|
-
run_seconds=match.run_seconds,
|
159
|
-
start_line=start_line,
|
160
|
-
job=job,
|
161
|
-
test_step=test_step_nr,
|
162
|
-
)
|
186
|
+
def group_by_name_package(cls, tests: list[GoTestRun]) -> dict[str, list[GoTestRun]]:
|
187
|
+
grouped = defaultdict(list)
|
188
|
+
for test in tests:
|
189
|
+
grouped[test.name_with_package].append(test)
|
190
|
+
return grouped
|
163
191
|
|
192
|
+
@classmethod
|
193
|
+
def pass_rate_or_skip_reason(cls, tests: list[GoTestRun], *, include_single_run: bool = False) -> tuple[float, str]:
|
194
|
+
if not tests:
|
195
|
+
return 0.0, "No tests"
|
196
|
+
fail_count = sum(test.is_pass for test in tests)
|
197
|
+
total_count = sum(GoTestStatus.is_pass_or_fail(test.status) for test in tests)
|
198
|
+
if not include_single_run and total_count == 1:
|
199
|
+
return 0.0, "Only one test and include_single_run is False"
|
200
|
+
if total_count == 0:
|
201
|
+
return 0.0, "No pass or fail tests"
|
202
|
+
return fail_count / total_count, ""
|
164
203
|
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
run_seconds: float | None = None
|
204
|
+
@classmethod
|
205
|
+
def last_pass(cls, tests: list[GoTestRun]) -> str:
|
206
|
+
last_pass = max((test for test in tests if test.is_pass), default=None)
|
207
|
+
return last_pass.when if last_pass else ""
|
170
208
|
|
171
|
-
@field_validator("ts", mode="before")
|
172
209
|
@classmethod
|
173
|
-
def
|
174
|
-
|
210
|
+
def last_pass_stats(cls, tests: list[GoTestRun], *, max_tests: int = 10) -> list[GoTestLastPassStat]:
|
211
|
+
"""Returns"""
|
212
|
+
pass_stats: list[GoTestLastPassStat] = []
|
213
|
+
for name_with_package, name_tests in cls.group_by_name_package(tests).items():
|
214
|
+
has_passes = bool(sum(test.is_pass for test in name_tests))
|
215
|
+
if not has_passes:
|
216
|
+
continue
|
217
|
+
has_failures = bool(sum(test.is_failure for test in name_tests))
|
218
|
+
if not has_failures:
|
219
|
+
continue
|
220
|
+
last_pass_test = max((test for test in name_tests if test.is_pass))
|
221
|
+
finish_ts = last_pass_test.finish_ts
|
222
|
+
assert finish_ts is not None, f"last_pass {last_pass_test} has no finish_ts"
|
223
|
+
pass_stats.append(GoTestLastPassStat(finish_ts, name_with_package, last_pass_test.when, last_pass_test))
|
224
|
+
return sorted(pass_stats)[:max_tests]
|
175
225
|
|
226
|
+
@classmethod
|
227
|
+
def lowest_pass_rate(
|
228
|
+
cls, tests: list[GoTestRun], *, max_tests: int = 10, include_single_run: bool = False
|
229
|
+
) -> list[tuple[float, str, list[GoTestRun]]]:
|
230
|
+
tests_with_pass_rates = []
|
231
|
+
grouped = cls.group_by_name_package(tests)
|
232
|
+
for name, tests in grouped.items():
|
233
|
+
pass_rate, skip_reason = cls.pass_rate_or_skip_reason(tests, include_single_run=include_single_run)
|
234
|
+
if skip_reason or pass_rate == 1.0:
|
235
|
+
continue
|
236
|
+
tests_with_pass_rates.append((pass_rate, name, tests))
|
237
|
+
return sorted(tests_with_pass_rates)[:max_tests]
|
176
238
|
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
)
|
239
|
+
@classmethod
|
240
|
+
def run_delta(cls, tests: list[GoTestRun]) -> str:
|
241
|
+
if not tests:
|
242
|
+
return "No tests"
|
243
|
+
run_dates = {run.ts.date() for run in tests}
|
244
|
+
if len(run_dates) == 1:
|
245
|
+
return f"on {run_dates.pop().strftime('%Y-%m-%d')}"
|
246
|
+
return f"from {min(run_dates).strftime('%Y-%m-%d')} to {max(run_dates).strftime('%Y-%m-%d')}"
|
184
247
|
|
248
|
+
@classmethod
|
249
|
+
def slowest_tests(cls, tests: list[GoTestRun], *, max_tests: int = 10) -> list[GoTestRuntimeStats]:
|
250
|
+
def run_time(test: GoTestRun) -> float:
|
251
|
+
return test.run_seconds or 0.0
|
252
|
+
|
253
|
+
slowest_tests = sorted(tests, key=run_time, reverse=True)
|
254
|
+
stats = []
|
255
|
+
grouped_by_name = cls.group_by_name_package(slowest_tests)
|
256
|
+
for slow_test in slowest_tests:
|
257
|
+
if slow_test.name_with_package not in grouped_by_name:
|
258
|
+
continue # already processed
|
259
|
+
runs = grouped_by_name.pop(slow_test.name_with_package)
|
260
|
+
slowest_seconds = max(run_time(test) for test in runs)
|
261
|
+
if slowest_seconds < 0.1: # ignore tests less than 0.1 seconds
|
262
|
+
return stats
|
263
|
+
average_seconds = sum(run_time(test) for test in runs) / len(runs) if len(runs) > 1 else None
|
264
|
+
stats.append(
|
265
|
+
GoTestRuntimeStats(
|
266
|
+
slowest_seconds=slowest_seconds,
|
267
|
+
average_seconds=average_seconds,
|
268
|
+
name_with_package=slow_test.name_with_package,
|
269
|
+
runs=runs,
|
270
|
+
)
|
271
|
+
)
|
272
|
+
if len(stats) >= max_tests:
|
273
|
+
return stats
|
274
|
+
return stats
|
275
|
+
|
276
|
+
|
277
|
+
class ParseResult(Entity):
|
278
|
+
tests: list[GoTestRun] = Field(default_factory=list)
|
279
|
+
|
280
|
+
@model_validator(mode="after")
|
281
|
+
def ensure_all_tests_completed(self) -> ParseResult:
|
282
|
+
if incomplete_tests := [
|
283
|
+
f"{test.name}-{test.status}" for test in self.tests if GoTestStatus.is_running(test.status)
|
284
|
+
]:
|
285
|
+
raise ValueError(f"some tests are not completed: {incomplete_tests}")
|
286
|
+
if no_package_tests := [(test.name, test.log_path) for test in self.tests if test.package_url is None]:
|
287
|
+
raise ValueError(f"some tests do not have package name: {no_package_tests}")
|
288
|
+
test_names = {test.name for test in self.tests}
|
289
|
+
test_group_names = {name.split("/")[0] for name in test_names if "/" in name}
|
290
|
+
self.tests = [test for test in self.tests if test.name not in test_group_names]
|
291
|
+
return self
|
292
|
+
|
293
|
+
|
294
|
+
@dataclass
|
295
|
+
class ParseContext:
|
296
|
+
tests: list[GoTestRun] = field(default_factory=list)
|
297
|
+
|
298
|
+
current_output: list[str] = field(default_factory=list, init=False)
|
299
|
+
current_test_name: str = "" # used for debugging and breakpoints
|
300
|
+
last_lines: deque = field(default_factory=lambda: deque(maxlen=10), init=False)
|
301
|
+
|
302
|
+
def add_output_line(self, line: str) -> None:
|
303
|
+
if is_blank_line(line) and self.current_output and is_blank_line(self.current_output[-1]):
|
304
|
+
return # avoid two blank lines in a row
|
305
|
+
self._add_line(line)
|
306
|
+
|
307
|
+
def _add_line(self, line: str) -> None:
|
308
|
+
logger.debug(f"adding line to {self.current_test_name}: {line}")
|
309
|
+
self.current_output.append(line)
|
310
|
+
|
311
|
+
def start_test(self, test_name: str, start_line: str, ts: str) -> None:
|
312
|
+
run = GoTestRun(name=test_name, ts=ts) # type: ignore
|
313
|
+
self.tests.append(run)
|
314
|
+
self.continue_test(test_name, start_line)
|
315
|
+
|
316
|
+
def continue_test(self, test_name: str, line: str) -> None:
|
317
|
+
test = self.find_unfinished_test(test_name)
|
318
|
+
self.current_output = test.output_lines
|
319
|
+
self.current_test_name = test_name
|
320
|
+
self._add_line(line)
|
321
|
+
|
322
|
+
def find_unfinished_test(self, test_name: str) -> GoTestRun:
|
323
|
+
test = next(
|
324
|
+
(test for test in self.tests if test.name == test_name and GoTestStatus.is_running(test.status)),
|
325
|
+
None,
|
326
|
+
)
|
327
|
+
assert test is not None, f"test {test_name} not found in context"
|
328
|
+
return test
|
185
329
|
|
186
|
-
def
|
187
|
-
|
330
|
+
def set_package(self, pkg_name: str) -> None:
|
331
|
+
for test in self.tests:
|
332
|
+
if test.package_url is None:
|
333
|
+
test.package_url = pkg_name
|
188
334
|
|
335
|
+
def finish_test(
|
336
|
+
self,
|
337
|
+
test_name: str,
|
338
|
+
status: GoTestStatus,
|
339
|
+
ts: str,
|
340
|
+
end_line: str,
|
341
|
+
run_seconds: float | None,
|
342
|
+
extra_lines: list[str] | None = None,
|
343
|
+
) -> None:
|
344
|
+
test = self.find_unfinished_test(test_name)
|
345
|
+
test.status = status
|
346
|
+
test.finish_ts = datetime.fromisoformat(ts)
|
347
|
+
if extra_lines:
|
348
|
+
test.output_lines.extend(extra_lines)
|
349
|
+
test.output_lines.append(end_line)
|
350
|
+
test.run_seconds = run_seconds
|
189
351
|
|
190
|
-
def
|
191
|
-
|
192
|
-
2024-06-26T04:41:47.7209465Z === RUN TestAccNetworkDSPrivateLinkEndpoint_basic
|
193
|
-
2024-06-26T04:41:47.7228652Z --- PASS: TestAccNetworkRSPrivateLinkEndpointGCP_basic (424.50s)
|
194
|
-
"""
|
195
|
-
if match := line_result.match(line):
|
196
|
-
line_match = LineMatch(**match.groupdict()) # type: ignore
|
197
|
-
return None if _test_name_is_nested(line_match.name, line) else line_match
|
198
|
-
return None
|
352
|
+
def finish_parsing(self) -> ParseResult:
|
353
|
+
return ParseResult(tests=self.tests)
|
199
354
|
|
200
355
|
|
201
|
-
|
202
|
-
r"(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z?)?\s?[-=]+\s" r"NAME\s+" r"(?P<name>[\w_]+)"
|
203
|
-
)
|
356
|
+
LineParserT: TypeAlias = Callable[[str, ParseContext], "LineParserT"]
|
204
357
|
|
205
358
|
|
206
|
-
def
|
207
|
-
|
208
|
-
return match.groupdict()["name"]
|
209
|
-
return ""
|
359
|
+
def ts_pattern(name: str) -> str:
|
360
|
+
return r"(?P<%s>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z?)\s*" % name
|
210
361
|
|
211
362
|
|
212
|
-
|
213
|
-
r"(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z?)?" r"\s{5}" r"(?P<indent>\s*)" r"(?P<relevant_line>.*)"
|
214
|
-
)
|
363
|
+
blank_pattern = re.compile(ts_pattern("ts") + r"$", re.MULTILINE)
|
215
364
|
|
216
365
|
|
217
|
-
def
|
218
|
-
|
219
|
-
match_vars = match.groupdict()
|
220
|
-
return match_vars["indent"] + match_vars["relevant_line"].strip()
|
221
|
-
return ""
|
366
|
+
def is_blank_line(line: str) -> bool:
|
367
|
+
return blank_pattern.match(line) is not None
|
222
368
|
|
223
369
|
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
370
|
+
_one_or_more_digit_or_star_pattern = r"(\d|\*)+" # due to Github secrets, some digits can be replaced with "*"
|
371
|
+
runtime_pattern_no_parenthesis = (
|
372
|
+
rf"(?P<runtime>{_one_or_more_digit_or_star_pattern}\.{_one_or_more_digit_or_star_pattern})s"
|
373
|
+
)
|
374
|
+
runtime_pattern = rf"\({runtime_pattern_no_parenthesis}\)"
|
375
|
+
|
376
|
+
|
377
|
+
ignore_line_pattern = [
|
378
|
+
re.compile(ts_pattern("ts") + r"\s" + ts_pattern("ts2")),
|
379
|
+
# 2025-04-29T00:44:02.9968072Z error=
|
380
|
+
# 2025-04-29T00:44:02.9968279Z | exit status 1
|
381
|
+
re.compile(ts_pattern("ts") + r"error=\s*$", re.MULTILINE),
|
382
|
+
re.compile(ts_pattern("ts") + r"\|"),
|
383
|
+
]
|
384
|
+
|
385
|
+
status_patterns = [
|
386
|
+
(GoTestStatus.RUN, re.compile(ts_pattern("ts") + r"=== RUN\s+(?P<name>\S+)")),
|
387
|
+
(GoTestStatus.PAUSE, re.compile(ts_pattern("ts") + r"=== PAUSE\s+(?P<name>\S+)")),
|
388
|
+
(GoTestStatus.NAME, re.compile(ts_pattern("ts") + r"=== NAME\s+(?P<name>\S+)")),
|
389
|
+
(GoTestStatus.CONT, re.compile(ts_pattern("ts") + r"=== CONT\s+(?P<name>\S+)")),
|
390
|
+
(
|
391
|
+
GoTestStatus.PASS,
|
392
|
+
re.compile(ts_pattern("ts") + r"--- PASS: (?P<name>\S+)\s+" + runtime_pattern),
|
393
|
+
),
|
394
|
+
(
|
395
|
+
GoTestStatus.FAIL,
|
396
|
+
re.compile(ts_pattern("ts") + r"--- FAIL: (?P<name>\S+)\s" + runtime_pattern),
|
397
|
+
),
|
398
|
+
(
|
399
|
+
GoTestStatus.SKIP,
|
400
|
+
re.compile(ts_pattern("ts") + r"--- SKIP: (?P<name>\S+)\s+" + runtime_pattern),
|
401
|
+
),
|
402
|
+
(
|
403
|
+
# 2025-06-06T05:30:18.9060127Z TestAccClusterAdvancedCluster_replicaSetAWSProvider (4h28m7s)
|
404
|
+
GoTestStatus.TIMEOUT,
|
405
|
+
re.compile(ts_pattern("ts") + r"\s+(?P<name>\S+)\s\((?P<hours>\d+)?h?(?P<minutes>\d+)?m(?P<seconds>\d+)?s\)"),
|
406
|
+
),
|
407
|
+
]
|
408
|
+
package_patterns = [
|
409
|
+
(
|
410
|
+
GoTestStatus.FAIL,
|
411
|
+
re.compile(ts_pattern("ts") + r"FAIL\s+(?P<package_url>\S+)\s+" + runtime_pattern_no_parenthesis),
|
412
|
+
),
|
413
|
+
(
|
414
|
+
GoTestStatus.FAIL,
|
415
|
+
re.compile(ts_pattern("ts") + r"FAIL\s+(?P<package_url>\S+)\s+\(cached\)"),
|
416
|
+
),
|
417
|
+
(
|
418
|
+
GoTestStatus.PKG_OK,
|
419
|
+
re.compile(ts_pattern("ts") + r"ok\s+(?P<package_url>\S+)\s+" + runtime_pattern_no_parenthesis),
|
420
|
+
),
|
421
|
+
(
|
422
|
+
GoTestStatus.PKG_OK,
|
423
|
+
re.compile(ts_pattern("ts") + r"ok\s+(?P<package_url>\S+)\s+\(cached\)"),
|
424
|
+
),
|
425
|
+
]
|
426
|
+
|
427
|
+
|
428
|
+
def line_match_status_pattern(
|
429
|
+
line: str,
|
430
|
+
context: ParseContext,
|
431
|
+
) -> GoTestStatus | None:
|
432
|
+
for status, pattern in status_patterns:
|
433
|
+
if pattern_match := pattern.match(line):
|
434
|
+
test_name = pattern_match.group("name")
|
435
|
+
assert test_name, f"test name not found in line: {line} when pattern matched {pattern}"
|
436
|
+
ts = pattern_match.group("ts")
|
437
|
+
assert ts, f"timestamp not found in line: {line} when pattern matched {pattern}"
|
438
|
+
match status:
|
439
|
+
case GoTestStatus.RUN:
|
440
|
+
context.start_test(test_name, line, ts)
|
441
|
+
case GoTestStatus.NAME | GoTestStatus.CONT:
|
442
|
+
context.continue_test(test_name, line)
|
443
|
+
case GoTestStatus.PAUSE:
|
444
|
+
return status # do nothing
|
445
|
+
case GoTestStatus.TIMEOUT:
|
446
|
+
hours = pattern_match.group("hours")
|
447
|
+
minutes = pattern_match.group("minutes")
|
448
|
+
seconds = pattern_match.group("seconds")
|
449
|
+
run_seconds = (
|
450
|
+
(int(hours) * 3600 if hours else 0)
|
451
|
+
+ (int(minutes) * 60 if minutes else 0)
|
452
|
+
+ (int(seconds) if seconds else 0)
|
453
|
+
)
|
454
|
+
last_two_lines = list(context.last_lines)[-2:]
|
455
|
+
context.finish_test(test_name, status, ts, line, run_seconds, extra_lines=last_two_lines)
|
456
|
+
case GoTestStatus.PASS | GoTestStatus.FAIL | GoTestStatus.SKIP:
|
457
|
+
run_time = pattern_match.group("runtime")
|
458
|
+
assert run_time, (
|
459
|
+
f"runtime not found in line with status={status}: {line} when pattern matched {pattern}"
|
460
|
+
)
|
461
|
+
seconds, milliseconds = run_time.split(".")
|
462
|
+
if "*" in seconds:
|
463
|
+
run_seconds = None
|
464
|
+
else:
|
465
|
+
run_seconds = int(seconds) + int(milliseconds.replace("*", "0")) / 1000
|
466
|
+
context.finish_test(test_name, status, ts, line, run_seconds)
|
467
|
+
return status
|
468
|
+
for pkg_status, pattern in package_patterns:
|
469
|
+
if pattern_match := pattern.match(line):
|
470
|
+
pkg_name = pattern_match.group("package_url")
|
471
|
+
assert pkg_name, f"package_url not found in line: {line} when pattern matched {pattern}"
|
472
|
+
context.set_package(pkg_name)
|
473
|
+
return pkg_status
|
474
|
+
return None
|
475
|
+
|
476
|
+
|
477
|
+
def wait_for_relvant_line(
|
478
|
+
line: str,
|
479
|
+
context: ParseContext,
|
480
|
+
) -> LineParserT:
|
481
|
+
status = line_match_status_pattern(line, context)
|
482
|
+
if status and GoTestStatus.is_running_but_not_paused(status):
|
483
|
+
return add_output_line
|
484
|
+
return wait_for_relvant_line
|
485
|
+
|
486
|
+
|
487
|
+
def add_output_line(
|
488
|
+
line: str,
|
489
|
+
context: ParseContext,
|
490
|
+
) -> LineParserT:
|
491
|
+
for pattern in ignore_line_pattern:
|
492
|
+
if pattern.match(line):
|
493
|
+
return add_output_line
|
494
|
+
status: GoTestStatus | None = line_match_status_pattern(line, context)
|
495
|
+
if status is None:
|
496
|
+
context.add_output_line(line)
|
497
|
+
return add_output_line
|
498
|
+
if status and GoTestStatus.is_running_but_not_paused(status):
|
499
|
+
return add_output_line
|
500
|
+
return wait_for_relvant_line
|