atlas-init 0.4.5__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.
Files changed (63) hide show
  1. atlas_init/__init__.py +1 -1
  2. atlas_init/cli.py +2 -0
  3. atlas_init/cli_cfn/cfn_parameter_finder.py +59 -51
  4. atlas_init/cli_cfn/example.py +8 -16
  5. atlas_init/cli_helper/go.py +6 -10
  6. atlas_init/cli_root/mms_released.py +46 -0
  7. atlas_init/cli_tf/app.py +3 -84
  8. atlas_init/cli_tf/ci_tests.py +493 -0
  9. atlas_init/cli_tf/codegen/__init__.py +0 -0
  10. atlas_init/cli_tf/codegen/models.py +97 -0
  11. atlas_init/cli_tf/codegen/openapi_minimal.py +74 -0
  12. atlas_init/cli_tf/github_logs.py +7 -94
  13. atlas_init/cli_tf/go_test_run.py +385 -132
  14. atlas_init/cli_tf/go_test_summary.py +331 -4
  15. atlas_init/cli_tf/go_test_tf_error.py +380 -0
  16. atlas_init/cli_tf/hcl/modifier.py +14 -12
  17. atlas_init/cli_tf/hcl/modifier2.py +87 -0
  18. atlas_init/cli_tf/mock_tf_log.py +1 -1
  19. atlas_init/cli_tf/{schema_v2_api_parsing.py → openapi.py} +95 -17
  20. atlas_init/cli_tf/schema_v2.py +43 -1
  21. atlas_init/crud/__init__.py +0 -0
  22. atlas_init/crud/mongo_client.py +115 -0
  23. atlas_init/crud/mongo_dao.py +296 -0
  24. atlas_init/crud/mongo_utils.py +239 -0
  25. atlas_init/repos/go_sdk.py +12 -3
  26. atlas_init/repos/path.py +110 -7
  27. atlas_init/settings/config.py +3 -6
  28. atlas_init/settings/env_vars.py +5 -1
  29. atlas_init/settings/interactive2.py +134 -0
  30. atlas_init/tf/.terraform.lock.hcl +59 -59
  31. atlas_init/tf/always.tf +5 -5
  32. atlas_init/tf/main.tf +3 -3
  33. atlas_init/tf/modules/aws_kms/aws_kms.tf +1 -1
  34. atlas_init/tf/modules/aws_s3/provider.tf +2 -1
  35. atlas_init/tf/modules/aws_vpc/provider.tf +2 -1
  36. atlas_init/tf/modules/cfn/cfn.tf +0 -8
  37. atlas_init/tf/modules/cfn/kms.tf +5 -5
  38. atlas_init/tf/modules/cfn/provider.tf +7 -0
  39. atlas_init/tf/modules/cfn/variables.tf +1 -1
  40. atlas_init/tf/modules/cloud_provider/cloud_provider.tf +1 -1
  41. atlas_init/tf/modules/cloud_provider/provider.tf +2 -1
  42. atlas_init/tf/modules/cluster/cluster.tf +31 -31
  43. atlas_init/tf/modules/cluster/provider.tf +2 -1
  44. atlas_init/tf/modules/encryption_at_rest/provider.tf +2 -1
  45. atlas_init/tf/modules/federated_vars/federated_vars.tf +1 -1
  46. atlas_init/tf/modules/federated_vars/provider.tf +2 -1
  47. atlas_init/tf/modules/project_extra/project_extra.tf +1 -10
  48. atlas_init/tf/modules/project_extra/provider.tf +8 -0
  49. atlas_init/tf/modules/stream_instance/provider.tf +8 -0
  50. atlas_init/tf/modules/stream_instance/stream_instance.tf +0 -9
  51. atlas_init/tf/modules/vpc_peering/provider.tf +10 -0
  52. atlas_init/tf/modules/vpc_peering/vpc_peering.tf +0 -10
  53. atlas_init/tf/modules/vpc_privatelink/versions.tf +2 -1
  54. atlas_init/tf/outputs.tf +1 -0
  55. atlas_init/tf/providers.tf +1 -1
  56. atlas_init/tf/variables.tf +7 -7
  57. atlas_init/typer_app.py +4 -8
  58. {atlas_init-0.4.5.dist-info → atlas_init-0.6.0.dist-info}/METADATA +7 -4
  59. atlas_init-0.6.0.dist-info/RECORD +121 -0
  60. atlas_init-0.4.5.dist-info/RECORD +0 -105
  61. {atlas_init-0.4.5.dist-info → atlas_init-0.6.0.dist-info}/WHEEL +0 -0
  62. {atlas_init-0.4.5.dist-info → atlas_init-0.6.0.dist-info}/entry_points.txt +0 -0
  63. {atlas_init-0.4.5.dist-info → atlas_init-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -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 Iterable
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 github.WorkflowJob import WorkflowJob
12
- from model_lib import Entity, Event, utc_datetime
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
- class Classification(StrEnum):
27
- OUT_OF_CAPACITY = "OUT_OF_CAPACITY"
28
- # DANGLING_RESOURCES = "DANGLING_RESOURCES"
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
- class LineInfo(Event):
36
- number: int
37
- text: str
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
- start_line: LineInfo
82
- ts: utc_datetime
83
- finish_ts: utc_datetime | None = None
84
- job: GoTestContext | WorkflowJob
85
- test_step: int
86
- log_path: Path | None = None
87
-
88
- finish_line: LineInfo | None = None
89
- context_lines: list[str] = Field(default_factory=list)
90
- run_seconds: float | None = None
91
-
92
- classifications: set[Classification] = Field(default_factory=set)
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 context_lines_str(self) -> str:
119
- return "\n".join(self.context_lines)
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 == GoTestStatus.FAIL
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 add_line_match(self, match: LineMatch, line: str, line_number: int) -> None:
139
- self.run_seconds = match.run_seconds or self.run_seconds
140
- self.finish_line = LineInfo(number=line_number, text=line)
141
- self.status = match.status
142
- self.finish_ts = match.ts
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 from_line_match(
146
- cls,
147
- match: LineMatch,
148
- line: str,
149
- line_number: int,
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
- class LineMatch(Event):
166
- ts: utc_datetime = Field(default_factory=utc_now)
167
- status: GoTestStatus
168
- name: str
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 remove_none(cls, v):
174
- return v or utc_now()
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
- _status_options = "|".join(list(GoTestStatus))
178
- line_result = re.compile(
179
- r"(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z?)?\s?[-=]+\s"
180
- + r"(?P<status>%s):?\s+" % _status_options # noqa: UP031
181
- + r"(?P<name>[\w_]+)"
182
- + r"\s*\(?(?P<run_seconds>[\d\.]+)?s?\)?"
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 _test_name_is_nested(name: str, line: str) -> bool:
187
- return f"{name}/" in line
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 match_line(line: str) -> LineMatch | None:
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
- context_start_pattern = re.compile(
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 context_start_match(line: str) -> str:
207
- if match := context_start_pattern.match(line):
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
- context_line_pattern = re.compile(
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 extract_context(line: str) -> str:
218
- if match := context_line_pattern.match(line):
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
- def parse(test_lines: list[str], job: WorkflowJob | GoTestContext, test_step_nr: int) -> Iterable[GoTestRun]:
225
- tests: dict[str, GoTestRun] = {}
226
- context_lines: list[str] = []
227
- current_context_test = ""
228
- for line_nr, line in enumerate(test_lines, start=0): # possibly an extra line in the log files we download
229
- if current_context_test:
230
- if more_context := extract_context(line):
231
- context_lines.append(more_context)
232
- continue
233
- else:
234
- tests[current_context_test].context_lines.extend(context_lines)
235
- context_lines.clear()
236
- current_context_test = ""
237
- if new_context_test := context_start_match(line):
238
- current_context_test = new_context_test
239
- continue
240
- if line_match := match_line(line):
241
- if existing := tests.pop(line_match.name, None):
242
- existing.add_line_match(line_match, line, line_nr)
243
- yield existing
244
- else:
245
- tests[line_match.name] = GoTestRun.from_line_match(line_match, line, line_nr, job, test_step_nr)
246
- if tests:
247
- logger.warning(f"unfinished tests: {sorted(tests.keys())}")
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