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
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from functools import cached_property
8
+ from pathlib import Path
9
+ from typing import ClassVar, Self
10
+
11
+ from model_lib import Entity, dump, field_names, parse_model
12
+ from motor.motor_asyncio import AsyncIOMotorCollection
13
+ from pydantic import model_validator
14
+ from zero_3rdparty.file_utils import ensure_parents_write_text
15
+ from zero_3rdparty.iter_utils import ignore_falsy
16
+
17
+ from atlas_init.cli_tf.go_test_run import GoTestRun
18
+ from atlas_init.cli_tf.go_test_tf_error import (
19
+ ErrorClassAuthor,
20
+ ErrorDetailsT,
21
+ GoTestAPIError,
22
+ GoTestError,
23
+ GoTestErrorClass,
24
+ GoTestErrorClassification,
25
+ GoTestResourceCheckError,
26
+ )
27
+ from atlas_init.crud.mongo_client import get_collection, init_mongo
28
+ from atlas_init.crud.mongo_utils import MongoQueryOperation, create_or_replace, dump_with_id
29
+ from atlas_init.repos.path import TFResoure, terraform_resources
30
+ from atlas_init.settings.env_vars import AtlasInitSettings
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def crud_dir(settings: AtlasInitSettings) -> Path:
36
+ return settings.static_root / "crud"
37
+
38
+
39
+ @dataclass
40
+ class TFResources:
41
+ resources: list[TFResoure] = field(default_factory=list)
42
+
43
+ def find_test_resources(self, test: GoTestRun) -> list[str]:
44
+ found_resources = []
45
+ for resource in self.resources:
46
+ url = test.package_url
47
+ if url and url.endswith(resource.package_rel_path):
48
+ found_resources.append(resource.name)
49
+ return found_resources
50
+
51
+
52
+ def read_tf_resources(settings: AtlasInitSettings, repo_path: Path, branch: str) -> TFResources:
53
+ return TFResources(resources=terraform_resources(repo_path))
54
+
55
+
56
+ class TFErrors(Entity):
57
+ errors: list[GoTestError] = field(default_factory=list)
58
+
59
+ @model_validator(mode="after")
60
+ def sort_errors(self) -> TFErrors:
61
+ self.errors.sort()
62
+ return self
63
+
64
+ def look_for_existing_classifications(self, error: GoTestError) -> tuple[GoTestErrorClass, GoTestErrorClass] | None:
65
+ for candidate in self.errors:
66
+ if error.match(candidate) and (classifications := candidate.classifications):
67
+ logger.info(f"found existing classification for {error.run.name}: {classifications}")
68
+ return classifications
69
+
70
+ def classified_errors(self) -> list[GoTestError]:
71
+ return [error for error in self.errors if error.classifications is not None]
72
+
73
+
74
+ def read_tf_errors(settings: AtlasInitSettings) -> TFErrors:
75
+ path = crud_dir(settings) / "tf_errors.yaml"
76
+ return parse_model(path, TFErrors) if path.exists() else TFErrors()
77
+
78
+
79
+ def read_tf_errors_for_day(settings: AtlasInitSettings, branch: str, date: datetime) -> list[GoTestError]:
80
+ raise NotImplementedError
81
+
82
+
83
+ def store_or_update_tf_errors(settings: AtlasInitSettings, errors: list[GoTestError]) -> None:
84
+ existing = read_tf_errors(settings)
85
+ new_error_ids = {error.run.id for error in errors}
86
+ existing_without_new = [error for error in existing.errors if error.run.id not in new_error_ids]
87
+ all_errors = existing_without_new + errors
88
+ yaml_dump = dump(TFErrors(errors=all_errors), "yaml")
89
+ ensure_parents_write_text(crud_dir(settings) / "tf_errors.yaml", yaml_dump)
90
+
91
+
92
+ def read_tf_error_by_run(settings: AtlasInitSettings, run: GoTestRun) -> GoTestError | None:
93
+ errors = read_tf_errors(settings)
94
+ return next((error for error in errors.errors if error.run.id == run.id), None)
95
+
96
+
97
+ class TFTestRuns(Entity):
98
+ test_runs: list[GoTestRun] = field(default_factory=list)
99
+
100
+ @model_validator(mode="after")
101
+ def sort_test_runs(self) -> TFTestRuns:
102
+ self.test_runs.sort()
103
+ return self
104
+
105
+
106
+ def read_tf_test_runs(settings: AtlasInitSettings) -> list[GoTestRun]:
107
+ path = crud_dir(settings) / "tf_test_runs.yaml"
108
+ return parse_model(path, TFTestRuns).test_runs if path.exists() else []
109
+
110
+
111
+ def read_tf_tests_for_day(settings: AtlasInitSettings, branch: str, date: datetime) -> list[GoTestRun]:
112
+ start_date = date.replace(hour=0, minute=0, second=0, microsecond=0)
113
+ end_date = start_date.replace(hour=23, minute=59, second=59, microsecond=999999)
114
+ return read_tf_tests(settings, branch, start_date, end_date)
115
+
116
+
117
+ def read_tf_tests(
118
+ settings: AtlasInitSettings, branch: str, start_date: datetime, end_date: datetime | None = None
119
+ ) -> list[GoTestRun]:
120
+ raise NotImplementedError
121
+
122
+
123
+ async def init_mongo_dao(settings: AtlasInitSettings) -> MongoDao:
124
+ dao = MongoDao(settings=settings)
125
+ return await dao.connect()
126
+
127
+
128
+ class GoTestRunNotFound(Exception):
129
+ def __init__(self, run_id: str) -> None:
130
+ self.run_id = run_id
131
+ super().__init__(run_id)
132
+
133
+
134
+ @dataclass
135
+ class MongoDao:
136
+ settings: AtlasInitSettings
137
+ property_keys_run: ClassVar[list[str]] = ["group_name"]
138
+
139
+ @cached_property
140
+ def runs(self) -> AsyncIOMotorCollection:
141
+ return get_collection(GoTestRun)
142
+
143
+ @cached_property
144
+ def classifications(self) -> AsyncIOMotorCollection:
145
+ return get_collection(GoTestErrorClassification)
146
+
147
+ @cached_property
148
+ def _field_names_runs(self) -> set[str]:
149
+ return set(field_names(GoTestRun)) | set(self.property_keys_run)
150
+
151
+ async def connect(self) -> Self:
152
+ await init_mongo(
153
+ mongo_url=self.settings.mongo_url,
154
+ db_name=self.settings.mongo_database,
155
+ )
156
+ return self
157
+
158
+ async def store_tf_test_runs(self, test_runs: list[GoTestRun]) -> list[GoTestRun]:
159
+ if not test_runs:
160
+ return []
161
+ col = self.runs
162
+ tasks = []
163
+ loop = asyncio.get_event_loop()
164
+ for run in test_runs:
165
+ dumped = dump_with_id(run, id=run.id, dt_keys=["ts", "finish_ts"], property_keys=self.property_keys_run)
166
+ tasks.append(loop.create_task(create_or_replace(col, dumped)))
167
+ await asyncio.gather(*tasks)
168
+ return test_runs
169
+
170
+ async def read_tf_tests_for_day(self, branch: str, date: datetime) -> list[GoTestRun]:
171
+ start_date = date.replace(hour=0, minute=0, second=0, microsecond=0)
172
+ end_date = start_date.replace(hour=23, minute=59, second=59, microsecond=999999)
173
+ query = {
174
+ "branch": branch,
175
+ "ts": {MongoQueryOperation.gte: start_date, MongoQueryOperation.lte: end_date},
176
+ }
177
+ return await self._find_runs(query)
178
+
179
+ async def _find_runs(self, query: dict) -> list[GoTestRun]:
180
+ runs = []
181
+ async for raw_run in self.runs.find(query):
182
+ runs.append(self._parse_run(raw_run))
183
+ return runs
184
+
185
+ async def read_error_classifications(
186
+ self, run_ids: list[str] | None = None
187
+ ) -> dict[str, GoTestErrorClassification]:
188
+ run_ids = run_ids or []
189
+ if not run_ids:
190
+ return {}
191
+ query = {"_id": {MongoQueryOperation.in_: run_ids}}
192
+ return await self._find_classifications(query)
193
+
194
+ async def _find_classifications(self, query: dict) -> dict[str, GoTestErrorClassification]:
195
+ classifications: dict[str, GoTestErrorClassification] = {}
196
+ async for raw_error in self.classifications.find(query):
197
+ run_id = raw_error.pop("_id", None)
198
+ classification = parse_model(raw_error, t=GoTestErrorClassification)
199
+ classifications[run_id] = classification
200
+ return classifications
201
+
202
+ async def read_similar_error_classifications(
203
+ self, details: ErrorDetailsT, *, author_filter: ErrorClassAuthor | None = None
204
+ ) -> dict[str, GoTestErrorClassification]:
205
+ query = {}
206
+ if author_filter:
207
+ query["author"] = {MongoQueryOperation.eq: author_filter}
208
+ match details:
209
+ case GoTestAPIError(
210
+ api_error_code_str=api_error_code_str,
211
+ api_method=api_method,
212
+ api_response_code=api_response_code,
213
+ api_path_normalized=api_path_normalized,
214
+ ) if api_path_normalized:
215
+ query |= {
216
+ "details.api_error_code_str": {MongoQueryOperation.eq: api_error_code_str},
217
+ "details.api_method": {MongoQueryOperation.eq: api_method},
218
+ "details.api_response_code": {MongoQueryOperation.eq: api_response_code},
219
+ "details.api_path_normalized": {MongoQueryOperation.eq: api_path_normalized},
220
+ }
221
+ case GoTestResourceCheckError(
222
+ tf_resource_name=tf_resource_name,
223
+ tf_resource_type=tf_resource_type,
224
+ step_nr=step_nr,
225
+ check_errors=check_errors,
226
+ test_name=test_name,
227
+ ):
228
+ query |= {
229
+ "details.tf_resource_name": {MongoQueryOperation.eq: tf_resource_name},
230
+ "details.tf_resource_type": {MongoQueryOperation.eq: tf_resource_type},
231
+ "details.step_nr": {MongoQueryOperation.eq: step_nr},
232
+ "test_name": {MongoQueryOperation.eq: test_name},
233
+ }
234
+ classifications = await self._find_classifications(query)
235
+ return {
236
+ run_id: classification
237
+ for run_id, classification in classifications.items()
238
+ if isinstance(classification.details, GoTestResourceCheckError)
239
+ and classification.details.check_errors_match(check_errors)
240
+ }
241
+ case _:
242
+ return {} # todo: vector search to match on error output
243
+ return await self._find_classifications(query)
244
+
245
+ async def add_classification(self, classification: GoTestErrorClassification) -> bool:
246
+ """Returns is_new"""
247
+ raw = dump_with_id(classification, id=classification.run_id, dt_keys=["ts"])
248
+ return await create_or_replace(self.classifications, raw)
249
+
250
+ async def read_tf_test_run(self, run_id: str) -> GoTestRun:
251
+ raw = await self.runs.find_one({"_id": run_id})
252
+ if raw is None:
253
+ raise GoTestRunNotFound(run_id)
254
+ return self._parse_run(raw)
255
+
256
+ def _parse_run(self, raw: dict) -> GoTestRun:
257
+ raw.pop("_id")
258
+ for key in self.property_keys_run:
259
+ raw.pop(key, None) # Remove properties that are not part of the model
260
+ return parse_model(raw, t=GoTestRun)
261
+
262
+ async def read_run_history(
263
+ self,
264
+ test_name: str,
265
+ branches: list[str] | None = None,
266
+ package_url: str | None = None,
267
+ group_name: str | None = None,
268
+ start_date: datetime | None = None,
269
+ end_date: datetime | None = None,
270
+ envs: list[str] | None = None,
271
+ ) -> list[GoTestRun]:
272
+ eq = MongoQueryOperation.eq
273
+ query = {
274
+ "name": {eq: test_name},
275
+ }
276
+ eq_parts = {
277
+ "package_url": {eq: package_url} if package_url else None,
278
+ "group_name": {eq: group_name} if group_name else None,
279
+ }
280
+ in_op = MongoQueryOperation.in_
281
+ in_parts = {
282
+ "branch": {in_op: branches} if branches else None,
283
+ "env": {in_op: envs} if envs else None,
284
+ }
285
+ date_parts = {
286
+ "ts": ignore_falsy(
287
+ **{
288
+ MongoQueryOperation.lte: end_date or None,
289
+ MongoQueryOperation.gte: start_date or None,
290
+ }
291
+ )
292
+ }
293
+ query |= ignore_falsy(**eq_parts, **in_parts, **date_parts)
294
+ if invalid_fields := set(query) - self._field_names_runs:
295
+ raise ValueError(f"Invalid fields in query: {invalid_fields}")
296
+ return await self._find_runs(query)
@@ -0,0 +1,239 @@
1
+ import logging
2
+ import re
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from functools import wraps
6
+ from typing import Any, AsyncIterable, Iterable, List, Optional, Type, TypeVar
7
+
8
+ from model_lib import dump_as_dict
9
+ from motor.core import AgnosticCollection
10
+ from pydantic import BaseModel
11
+ from pymongo import ASCENDING, DESCENDING, IndexModel, ReturnDocument
12
+ from pymongo.errors import DuplicateKeyError, PyMongoError
13
+ from pymongo.results import DeleteResult
14
+ from zero_3rdparty.enum_utils import StrEnum
15
+
16
+ logger = logging.getLogger(__name__)
17
+ ModelT = TypeVar("ModelT", bound=BaseModel)
18
+
19
+
20
+ class MongoUpdateOperation(StrEnum):
21
+ """
22
+ References:
23
+ https://docs.mongodb.com/manual/reference/operator/update-array/
24
+ """
25
+
26
+ slice = "$slice"
27
+ pop = "$pop"
28
+ pull = "$pull"
29
+ unset = "$unset"
30
+ push = "$push"
31
+ each = "$each"
32
+ set = "$set"
33
+ set_on_insert = "$setOnInsert"
34
+ inc = "$inc"
35
+
36
+
37
+ UPDATE_OPERATIONS = set(MongoUpdateOperation)
38
+
39
+
40
+ def ensure_mongo_operation(updates: dict):
41
+ """
42
+ >>> ensure_mongo_operation({"field1": 2})
43
+ {'$set': {'field1': 2}}
44
+ >>> ensure_mongo_operation({MongoUpdateOperation.set: {"field1": 2}})
45
+ {'$set': {'field1': 2}}
46
+ >>> ensure_mongo_operation({MongoUpdateOperation.push: {"field1": 2}})
47
+ {'$push': {'field1': 2}}
48
+ """
49
+ if updates.keys() - UPDATE_OPERATIONS == set():
50
+ return updates
51
+ return {MongoUpdateOperation.set: updates}
52
+
53
+
54
+ class MongoQueryOperation(StrEnum):
55
+ # must be used when checking if a boolean field is false
56
+ eq = "$eq"
57
+ # https://stackoverflow.com/questions/18837486/query-for-boolean-field-as-not-true-e-g-either-false-or-non-existent
58
+ ne = "$ne"
59
+ in_ = "$in"
60
+ # https://www.mongodb.com/docs/manual/reference/operator/query/nin/#mongodb-query-op.-nin
61
+ nin = "$nin"
62
+ gt = "$gt"
63
+ gte = "$gte"
64
+ lt = "$lt"
65
+ lte = "$lte"
66
+ slice = "$slice"
67
+
68
+ @classmethod
69
+ def boolean_or_none(cls, bool_value: bool | None) -> dict | None:
70
+ if bool_value is None:
71
+ return None
72
+ return {cls.eq: True} if bool_value else {cls.ne: True}
73
+
74
+ @classmethod
75
+ def in_or_none(cls, options: Iterable[Any] | None) -> dict | None:
76
+ return None if options is None else {cls.in_: list(options)}
77
+
78
+ @classmethod
79
+ def nin_or_none(cls, options: Iterable[Any] | None) -> dict | None:
80
+ return None if options is None else {cls.nin: list(options)}
81
+
82
+
83
+ duplicate_key_regex = re.compile(
84
+ r".*error collection:"
85
+ r"\s(?P<collection_path>[-\w\d\\.]+)"
86
+ r"\sindex:\s"
87
+ r"(?P<index_name>[\w_\\.\d]+)"
88
+ r"\sdup key.*?"
89
+ r'(?P<dup_key_value>("?[\\.\w_\d]+"?)|(null))'
90
+ )
91
+
92
+
93
+ @dataclass
94
+ class MongoConstraintDetails:
95
+ collection_path: str
96
+ index_name: str
97
+ dup_key_value: Optional[str]
98
+
99
+ def __post_init__(self):
100
+ if self.dup_key_value:
101
+ self.dup_key_value = self.dup_key_value.strip('"')
102
+ if self.dup_key_value == "null":
103
+ self.dup_key_value = None
104
+
105
+
106
+ def parse_error(error: PyMongoError) -> Optional[MongoConstraintDetails]:
107
+ """
108
+ >>> raw = 'E11000 duplicate key error collection: dev_situation.Robot index: _id_ dup key: { : "mw_wheel_id" }'
109
+ >>> parse_error(raw)
110
+ MongoConstraintDetails(collection_path='dev_situation.Robot', index_name='_id_', dup_key_value='mw_wheel_id')
111
+ ''
112
+ """
113
+ error_str = str(error)
114
+ for m in duplicate_key_regex.finditer(error_str):
115
+ constraints = MongoConstraintDetails(**m.groupdict())
116
+ if isinstance(error, DuplicateKeyError):
117
+ _, constraints.dup_key_value = error.details["keyValue"].popitem() # type: ignore
118
+ return constraints
119
+ logger.warning(f"unknown pymongo error:{error}")
120
+
121
+
122
+ class MongoConstraintError(Exception):
123
+ def __init__(self, details: MongoConstraintDetails):
124
+ self.details: MongoConstraintDetails = details
125
+
126
+
127
+ T = TypeVar("T")
128
+
129
+ ConstraintSubT = TypeVar("ConstraintSubT", bound=MongoConstraintError)
130
+
131
+
132
+ def raise_mongo_constraint_error(f: T = None, *, cls: Type[ConstraintSubT] = MongoConstraintError) -> T:
133
+ def decorator(f: T):
134
+ @wraps(f) # type: ignore
135
+ async def inner(*args, **kwargs):
136
+ try:
137
+ return await f(*args, **kwargs) # type: ignore
138
+ except PyMongoError as e:
139
+ if details := parse_error(e):
140
+ raise cls(details) from e
141
+ raise e
142
+
143
+ return inner
144
+
145
+ return decorator(f) if f else decorator # type: ignore
146
+
147
+
148
+ def dump_with_id(
149
+ model: BaseModel,
150
+ id: str = "",
151
+ dt_keys: Optional[List[str]] = None,
152
+ property_keys: Optional[List[str]] = None,
153
+ exclude: Optional[set[str]] = None,
154
+ ) -> dict:
155
+ """
156
+ Warning:
157
+ If you want to index on datetime, you have to set them afterwards
158
+ As they will be dumped as strings
159
+ """
160
+ raw = dump_as_dict(model) if exclude is None else dump_as_dict(model.model_dump(exclude=exclude))
161
+ if id:
162
+ raw["_id"] = id
163
+ if dt_keys:
164
+ for key in dt_keys:
165
+ raw[key] = getattr(model, key)
166
+ if property_keys:
167
+ for key in property_keys:
168
+ raw[key] = getattr(model, key)
169
+ return raw
170
+
171
+
172
+ async def create_or_replace(collection: AgnosticCollection, raw: dict) -> bool:
173
+ """
174
+ Returns:
175
+ is_new: bool
176
+ """
177
+ result = await collection.replace_one({"_id": raw["_id"]}, raw, upsert=True)
178
+ return bool(result.upserted_id)
179
+
180
+
181
+ async def find_one_and_update(
182
+ collection: AgnosticCollection,
183
+ id: str,
184
+ updates: dict,
185
+ return_raw_after: bool = True,
186
+ upsert: bool = False,
187
+ **query,
188
+ ) -> Optional[dict]:
189
+ """
190
+ Warning:
191
+ pops the "_id" from serialize_lib
192
+ """
193
+ return_doc = ReturnDocument.AFTER if return_raw_after else ReturnDocument.BEFORE
194
+ updates = ensure_mongo_operation(updates)
195
+ raw = await collection.find_one_and_update({"_id": id, **query}, updates, return_document=return_doc, upsert=upsert)
196
+ if raw:
197
+ raw.pop("_id", None)
198
+ return raw
199
+
200
+
201
+ def microsecond_compare(mongo_dt: datetime, dt: datetime) -> bool:
202
+ """Mongo only stores milliseconds since epoch
203
+ https://stackoverflow.com/questions/39963143/why-is-there-a-difference-
204
+ between-the-stored-and-queried-time-in-mongo-database."""
205
+ with_microseconds = mongo_dt.replace(microsecond=dt.microsecond)
206
+ return with_microseconds == dt and (mongo_dt - dt).total_seconds() < 0.001
207
+
208
+
209
+ def safe_key(key: str) -> str:
210
+ return key.replace(".", "_DOT_")
211
+
212
+
213
+ def replace_dot_keys(values: dict) -> dict:
214
+ """avoid InvalidDocument("key 'dev.amironenko' must not contain '.'")"""
215
+ return {safe_key(key): value for key, value in values.items()}
216
+
217
+
218
+ def decode_delete_count(result: DeleteResult) -> int:
219
+ return result.deleted_count
220
+
221
+
222
+ def push_and_limit_length_update(field_name: str, new_value: Any, max_size: int) -> dict:
223
+ return {
224
+ MongoUpdateOperation.push: {
225
+ field_name: {
226
+ MongoUpdateOperation.each: [new_value],
227
+ MongoUpdateOperation.slice: -max_size,
228
+ }
229
+ }
230
+ }
231
+
232
+
233
+ def index_dec(column: str) -> IndexModel:
234
+ return IndexModel([(column, DESCENDING)])
235
+
236
+
237
+ def query_and_sort(collection: AgnosticCollection, query: dict, sort_col: str, desc: bool) -> AsyncIterable[dict]:
238
+ sort_order = DESCENDING if desc else ASCENDING
239
+ return collection.find(query).sort(sort_col, sort_order)
File without changes
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ from concurrent.futures import Future
4
+ from contextlib import suppress
5
+ from datetime import datetime
6
+ from typing import ClassVar
7
+ from ask_shell import ShellRun, confirm, kill, run, run_and_wait
8
+ from ask_shell.models import ShellRunEventT, ShellRunStdOutput
9
+ from zero_3rdparty import str_utils
10
+ from zero_3rdparty.file_utils import copy, ensure_parents_write_text
11
+ from zero_3rdparty.future import chain_future
12
+ from atlas_init.settings.env_vars import AtlasInitSettings
13
+ from pathlib import Path
14
+ from model_lib import Event
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class MonthlyReportPaths(Event):
20
+ summary_path: Path
21
+ error_only_path: Path
22
+ details_dir: Path
23
+ summary_name: str
24
+ daily_path: Path
25
+
26
+ ERROR_ONLY_SUFFIX: ClassVar[str] = "_error-only.md"
27
+ DAILY_SUFFIX: ClassVar[str] = "_daily.md"
28
+
29
+ def export_to_dir(self, out_dir: Path) -> None:
30
+ for path in [self.summary_path, self.error_only_path, self.daily_path]:
31
+ if path.exists():
32
+ ensure_parents_write_text(out_dir / path.name, path.read_text())
33
+ if self.details_dir.exists():
34
+ copy(self.details_dir, out_dir / self.details_dir.name, clean_dest=True)
35
+
36
+ @classmethod
37
+ def from_settings(cls, settings: AtlasInitSettings, summary_name: str) -> MonthlyReportPaths:
38
+ summary_path = settings.github_ci_summary_dir / str_utils.ensure_suffix(summary_name, ".md")
39
+ return cls(
40
+ summary_path=summary_path,
41
+ error_only_path=settings.github_ci_summary_dir
42
+ / str_utils.ensure_suffix(summary_name, MonthlyReportPaths.ERROR_ONLY_SUFFIX),
43
+ details_dir=settings.github_ci_summary_details_path(summary_name, "dummy").parent,
44
+ summary_name=summary_name,
45
+ daily_path=settings.github_ci_summary_dir / f"{summary_path.stem}{MonthlyReportPaths.DAILY_SUFFIX}",
46
+ )
47
+
48
+
49
+ CI_TESTS_DIR_NAME = "ci-tests"
50
+ MKDOCS_SERVE_TIMEOUT = 120
51
+ MKDOCS_SERVE_URL = "http://127.0.0.1:8000"
52
+
53
+
54
+ def export_ci_tests_markdown_to_html(settings: AtlasInitSettings, report_paths: MonthlyReportPaths) -> None:
55
+ html_out = settings.atlas_init_static_html_path
56
+ if not html_out or not html_out.exists():
57
+ return
58
+ ci_tests_dir = html_out / CI_TESTS_DIR_NAME
59
+ docs_out_dir = ci_tests_dir / "docs"
60
+ report_paths.export_to_dir(docs_out_dir)
61
+ index_md_content = create_index_md(docs_out_dir)
62
+ ensure_parents_write_text(docs_out_dir / "index.md", index_md_content)
63
+ server_url, run_event = start_mkdocs_serve(ci_tests_dir)
64
+ try:
65
+ if confirm(f"do you want to open the html docs? {server_url}", default=False):
66
+ run_and_wait(f'open -a "Google Chrome" {server_url}')
67
+ if confirm("Finished testing html docs?", default=False):
68
+ pass
69
+ except BaseException as e:
70
+ raise e
71
+ finally:
72
+ kill(run_event, reason="Done with html docs check")
73
+ if confirm("Are docs ok to build and push?", default=False):
74
+ build_and_push(ci_tests_dir, report_paths.summary_name)
75
+
76
+
77
+ def create_index_md(docs_out_dir: Path) -> str:
78
+ """
79
+ tree -L 1 docs
80
+ docs
81
+ ├── 2025-06-26_details
82
+ ├── 2025-06-26_.md
83
+ ├── 2025-06-26.md
84
+ ├── 2025-06-26_error-only.md
85
+ ├── index.md
86
+ ├── javascript
87
+ └── stylesheets
88
+ """
89
+ md_files = {f.name: f for f in docs_out_dir.glob("*.md") if f.name != "index.md"}
90
+ parsed_dates = []
91
+ for md_file in md_files.values():
92
+ with suppress(ValueError):
93
+ parsed_dates.append(datetime.strptime(md_file.stem, "%Y-%m-%d"))
94
+ parsed_dates.sort(reverse=True)
95
+
96
+ def date_row(date: datetime) -> str:
97
+ summary_filename = f"{date.strftime('%Y-%m-%d')}.md"
98
+ error_only_filename = f"{date.strftime('%Y-%m-%d')}{MonthlyReportPaths.ERROR_ONLY_SUFFIX}"
99
+ daily_filename = f"{date.strftime('%Y-%m-%d')}{MonthlyReportPaths.DAILY_SUFFIX}"
100
+ line_links = [f"[{date.strftime('%Y-%m-%d')}](./{summary_filename})"]
101
+ if error_only_filename in md_files:
102
+ line_links.append(f"[{date.strftime('%Y-%m-%d')} Error Only](./{error_only_filename})")
103
+ if daily_filename in md_files:
104
+ line_links.append(f"[{date.strftime('%Y-%m-%d')} Daily Errors](./{daily_filename})")
105
+ return f"- {', '.join(line_links)}"
106
+
107
+ md_content = [
108
+ "# Welcome to CI Tests",
109
+ "",
110
+ *[date_row(dt) for dt in parsed_dates],
111
+ "",
112
+ ]
113
+ return "\n".join(md_content)
114
+
115
+
116
+ def start_mkdocs_serve(ci_tests_dir: Path) -> tuple[str, ShellRun]:
117
+ future = Future()
118
+
119
+ def on_message(event: ShellRunEventT) -> bool:
120
+ match event:
121
+ case ShellRunStdOutput(_, content) if f"Serving on {MKDOCS_SERVE_URL}" in content:
122
+ logger.info(f"Docs server ready @ {MKDOCS_SERVE_URL}")
123
+ future.set_result(None)
124
+ return True
125
+ return False
126
+
127
+ run_event = run(
128
+ "uv run mkdocs serve", cwd=ci_tests_dir, message_callbacks=[on_message], print_prefix="mkdocs serve"
129
+ )
130
+ chain_future(run_event._complete_flag, future)
131
+ try:
132
+ future.result(timeout=MKDOCS_SERVE_TIMEOUT)
133
+ except BaseException as e:
134
+ kill(run_event, reason=f"Failed to start mkdocs serve, timeout after {MKDOCS_SERVE_TIMEOUT} seconds")
135
+ raise e
136
+ return MKDOCS_SERVE_URL, run_event
137
+
138
+
139
+ def build_and_push(ci_tests_dir: Path, summary_name: str) -> None:
140
+ run_and_wait("uv run mkdocs build", cwd=ci_tests_dir, print_prefix="build")
141
+ run_and_wait("git add .", cwd=ci_tests_dir, print_prefix="add")
142
+ run_and_wait(f"git commit -m 'update ci tests {summary_name}'", cwd=ci_tests_dir, print_prefix="commit")
143
+ run_and_wait("git push", cwd=ci_tests_dir, print_prefix="push")