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