atlas-init 0.4.3__py3-none-any.whl → 0.4.5__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 (30) hide show
  1. atlas_init/__init__.py +2 -3
  2. atlas_init/cli.py +2 -1
  3. atlas_init/cli_cfn/app.py +4 -7
  4. atlas_init/cli_cfn/cfn_parameter_finder.py +3 -30
  5. atlas_init/cli_cfn/contract.py +9 -10
  6. atlas_init/cli_cfn/example.py +8 -8
  7. atlas_init/cli_helper/go.py +18 -14
  8. atlas_init/cli_helper/tf_runner.py +14 -11
  9. atlas_init/cli_root/trigger.py +24 -11
  10. atlas_init/cli_tf/app.py +1 -1
  11. atlas_init/cli_tf/github_logs.py +4 -16
  12. atlas_init/cli_tf/hcl/modifier.py +115 -14
  13. atlas_init/cli_tf/mock_tf_log.py +3 -2
  14. atlas_init/cli_tf/schema_v3.py +2 -2
  15. atlas_init/cli_tf/schema_v3_sdk_base.py +1 -1
  16. atlas_init/settings/env_vars.py +130 -166
  17. atlas_init/settings/env_vars_generated.py +40 -9
  18. atlas_init/settings/env_vars_modules.py +71 -0
  19. atlas_init/settings/path.py +3 -9
  20. atlas_init/typer_app.py +3 -3
  21. {atlas_init-0.4.3.dist-info → atlas_init-0.4.5.dist-info}/METADATA +2 -2
  22. {atlas_init-0.4.3.dist-info → atlas_init-0.4.5.dist-info}/RECORD +25 -29
  23. atlas_init/cli_tf/example_update_test/test_update_example.tf +0 -23
  24. atlas_init/cli_tf/example_update_test.py +0 -96
  25. atlas_init/cli_tf/hcl/modifier_test/test_process_variables_output_.tf +0 -25
  26. atlas_init/cli_tf/hcl/modifier_test/test_process_variables_variable_.tf +0 -24
  27. atlas_init/cli_tf/hcl/modifier_test.py +0 -95
  28. {atlas_init-0.4.3.dist-info → atlas_init-0.4.5.dist-info}/WHEEL +0 -0
  29. {atlas_init-0.4.3.dist-info → atlas_init-0.4.5.dist-info}/entry_points.txt +0 -0
  30. {atlas_init-0.4.3.dist-info → atlas_init-0.4.5.dist-info}/licenses/LICENSE +0 -0
@@ -2,6 +2,7 @@ import logging
2
2
  from collections import defaultdict
3
3
  from copy import deepcopy
4
4
  from pathlib import Path
5
+ from typing import Callable
5
6
 
6
7
  import hcl2
7
8
  from lark import Token, Tree, UnexpectedToken
@@ -51,6 +52,8 @@ def update_description(tree: Tree, new_descriptions: dict[str, str], existing_na
51
52
  def token_name(token: Token | Tree) -> str:
52
53
  if isinstance(token, Token):
53
54
  return token.value.strip('"')
55
+ if isinstance(token, Tree) and token.data == "identifier":
56
+ return token.children[0].value.strip('"') # type: ignore
54
57
  err_msg = f"unexpected token type {type(token)} for token name"
55
58
  raise ValueError(err_msg)
56
59
 
@@ -102,31 +105,46 @@ def create_description_attribute(description_value: str) -> Tree:
102
105
  return Tree(Token("RULE", "attribute"), children)
103
106
 
104
107
 
105
- def process_descriptions(
108
+ def process_generic(
106
109
  node: Tree,
107
- name_updates: dict[str, str],
108
- existing_names: dict[str, list[str]],
110
+ tree_match: Callable[[Tree], bool],
111
+ tree_call: Callable[[Tree], Tree],
109
112
  depth=0,
110
- *,
111
- block_type: str,
112
- ) -> Tree:
113
+ ):
113
114
  new_children = []
114
115
  logger.debug(f"[{depth}] (tree)\t|", " " * depth, node.data)
115
116
  for child in node.children:
116
117
  if isinstance(child, Tree):
117
- if is_block_type(child, block_type):
118
- child = update_description( # noqa: PLW2901
119
- child, name_updates, existing_names
120
- )
121
- new_children.append(
122
- process_descriptions(child, name_updates, existing_names, depth + 1, block_type=block_type)
123
- )
118
+ if tree_match(child):
119
+ child = tree_call(child)
120
+ new_children.append(process_generic(child, tree_match, tree_call, depth + 1))
124
121
  else:
125
122
  new_children.append(process_token(child, depth + 1))
126
-
127
123
  return Tree(node.data, new_children)
128
124
 
129
125
 
126
+ def process_descriptions(
127
+ node: Tree,
128
+ name_updates: dict[str, str],
129
+ existing_names: dict[str, list[str]],
130
+ depth=0,
131
+ *,
132
+ block_type: str,
133
+ ) -> Tree:
134
+ def tree_match(tree: Tree) -> bool:
135
+ return is_block_type(tree, block_type)
136
+
137
+ def tree_call(tree: Tree) -> Tree:
138
+ return update_description(tree, name_updates, existing_names)
139
+
140
+ return process_generic(
141
+ node,
142
+ tree_match,
143
+ tree_call,
144
+ depth=depth,
145
+ )
146
+
147
+
130
148
  def update_descriptions(tf_path: Path, new_names: dict[str, str], block_type: str) -> tuple[str, dict[str, list[str]]]:
131
149
  try:
132
150
  tree = hcl2.parses(tf_path.read_text()) # type: ignore
@@ -142,3 +160,86 @@ def update_descriptions(tf_path: Path, new_names: dict[str, str], block_type: st
142
160
  )
143
161
  new_tf = hcl2.writes(new_tree) # type: ignore
144
162
  return new_tf, existing_descriptions
163
+
164
+
165
+ def _block_name_body(tree: Tree) -> tuple[str, Tree]:
166
+ try:
167
+ _, name_token, body = tree.children
168
+ name = token_name(name_token)
169
+ except (IndexError, AttributeError) as e:
170
+ raise ValueError("unexpected block structure") from e
171
+ return name, body
172
+
173
+
174
+ def _read_attribute(tree_body: Tree, attribute_name: str) -> Tree | None:
175
+ for attribute in tree_body.children:
176
+ if not isinstance(attribute, Tree):
177
+ continue
178
+ if attribute.data != "attribute":
179
+ continue
180
+ attr_identifier, _, attr_value = attribute.children
181
+ if token_name(attr_identifier.children[0]) != attribute_name:
182
+ continue
183
+ return attr_value
184
+
185
+
186
+ def _is_object(tree_body: Tree) -> bool:
187
+ if not isinstance(tree_body, Tree):
188
+ return False
189
+ if len(tree_body.children) != 1:
190
+ return False
191
+ if not isinstance(tree_body.children[0], Tree):
192
+ return False
193
+ return tree_body.children[0].data == "object"
194
+
195
+
196
+ def _read_object_elems(tree_body: Tree) -> list[Tree]:
197
+ object_elements = []
198
+ for obj_child in tree_body.children[0].children:
199
+ if not isinstance(obj_child, Tree):
200
+ continue
201
+ if obj_child.data != "object_elem":
202
+ continue
203
+ object_elements.append(obj_child)
204
+ return object_elements
205
+
206
+
207
+ def _read_object_elem_key(tree_body: Tree) -> str:
208
+ name_tree, _, _ = tree_body.children
209
+ return token_name(name_tree.children[0])
210
+
211
+
212
+ def read_block_attribute_object_keys(tf_path: Path, block_type: str, block_name: str, block_key: str) -> list[str]:
213
+ try:
214
+ tree = hcl2.parses(tf_path.read_text()) # type: ignore
215
+ except UnexpectedToken as e:
216
+ logger.warning(f"failed to parse {tf_path}: {e}")
217
+ return []
218
+ env_vars = []
219
+
220
+ def extract_env_vars(tree: Tree) -> bool:
221
+ if not is_block_type(tree, block_type):
222
+ return False
223
+ name, body = _block_name_body(tree)
224
+ if name != block_name:
225
+ return False
226
+ attribute_value = _read_attribute(body, block_key)
227
+ if not attribute_value:
228
+ return False
229
+ if not _is_object(attribute_value):
230
+ return False
231
+ object_elements = _read_object_elems(attribute_value)
232
+ for obj_elem in object_elements:
233
+ key = _read_object_elem_key(obj_elem)
234
+ env_vars.append(key)
235
+ return False
236
+
237
+ def tree_call(tree: Tree) -> Tree:
238
+ return tree
239
+
240
+ process_generic(
241
+ tree,
242
+ extract_env_vars,
243
+ tree_call,
244
+ )
245
+ return env_vars
@@ -33,7 +33,7 @@ from atlas_init.repos.go_sdk import (
33
33
  download_admin_api,
34
34
  parse_api_spec_paths,
35
35
  )
36
- from atlas_init.settings.path import DEFAULT_DOWNLOADS_DIR
36
+ from atlas_init.settings.env_vars import init_settings
37
37
 
38
38
  logger = logging.getLogger(__name__)
39
39
 
@@ -164,7 +164,8 @@ def resolve_admin_api_path(sdk_repo_path_str: str, sdk_branch: str, admin_api_pa
164
164
  assert sdk_repo_path.exists(), f"not found sdk_repo_path={sdk_repo_path}"
165
165
  resolved_admin_api_path = api_spec_path_transformed(sdk_repo_path)
166
166
  else:
167
- resolved_admin_api_path = DEFAULT_DOWNLOADS_DIR / "atlas-api-transformed.yaml"
167
+ settings = init_settings()
168
+ resolved_admin_api_path = settings.atlas_atlas_api_transformed_yaml
168
169
  if not is_cache_up_to_date(resolved_admin_api_path, 3600):
169
170
  download_admin_api(resolved_admin_api_path, sdk_branch)
170
171
  assert resolved_admin_api_path.exists(), f"unable to resolve admin_api_path={resolved_admin_api_path}"
@@ -210,12 +210,12 @@ class Schema(BaseModelLocal):
210
210
 
211
211
 
212
212
  class Resource(BaseModelLocal):
213
- schema: Schema # pyright: ignore
213
+ local_schema: Schema = Field(alias="schema")
214
214
  name: SnakeCaseString
215
215
 
216
216
  @property
217
217
  def use_timeout(self) -> bool:
218
- return any(a.timeouts for a in self.schema.attributes)
218
+ return any(a.timeouts for a in self.local_schema.attributes)
219
219
 
220
220
 
221
221
  ResourceSchemaV3 = Resource
@@ -41,7 +41,7 @@ def schema_attributes(root: object) -> list[Attribute]:
41
41
 
42
42
  @schema_attributes.register
43
43
  def _resource_attributes(root: Resource) -> list[Attribute]:
44
- return root.schema.attributes
44
+ return root.local_schema.attributes
45
45
 
46
46
 
47
47
  @schema_attributes.register
@@ -7,11 +7,10 @@ from functools import cached_property
7
7
  from pathlib import Path
8
8
  from typing import Any, NamedTuple, TypeVar
9
9
 
10
- from model_lib import parse_payload
11
- from pydantic import ValidationError, field_validator, model_validator
12
- from pydantic_settings import BaseSettings, SettingsConfigDict
10
+ from model_lib import StaticSettings, parse_payload
11
+ from pydantic import BaseModel, ValidationError, field_validator
12
+ from zero_3rdparty import iter_utils
13
13
 
14
- from atlas_init.cloud.aws import AwsRegion
15
14
  from atlas_init.settings.config import (
16
15
  AtlasInitConfig,
17
16
  TestSuite,
@@ -19,12 +18,10 @@ from atlas_init.settings.config import (
19
18
  from atlas_init.settings.config import (
20
19
  active_suites as config_active_suites,
21
20
  )
22
- from atlas_init.settings.env_vars_generated import AtlasSettings
23
21
  from atlas_init.settings.path import (
24
- DEFAULT_CONFIG_PATH,
25
- DEFAULT_PROFILES_PATH,
26
- DEFAULT_SCHEMA_CONFIG_PATH,
27
- DEFAULT_TF_PATH,
22
+ DEFAULT_ATLAS_INIT_CONFIG_PATH,
23
+ DEFAULT_ATLAS_INIT_SCHEMA_CONFIG_PATH,
24
+ DEFAULT_TF_SRC_PATH,
28
25
  dump_dotenv,
29
26
  load_dotenv,
30
27
  repo_path_rel_path,
@@ -33,66 +30,59 @@ from atlas_init.settings.path import (
33
30
  logger = logging.getLogger(__name__)
34
31
  ENV_PREFIX = "ATLAS_INIT_"
35
32
  DEFAULT_PROFILE = "default"
33
+ DEFAULT_PROJECT_NAME = "atlas-init"
36
34
  ENV_S3_PROFILE_BUCKET = f"{ENV_PREFIX}S3_PROFILE_BUCKET"
37
35
  ENV_PROJECT_NAME = f"{ENV_PREFIX}PROJECT_NAME"
38
36
  ENV_PROFILE = f"{ENV_PREFIX}PROFILE"
39
- ENV_PROFILES_PATH = f"{ENV_PREFIX}PROFILES_PATH"
40
37
  ENV_TEST_SUITES = f"{ENV_PREFIX}TEST_SUITES"
41
38
  ENV_CLIPBOARD_COPY = f"{ENV_PREFIX}CLIPBOARD_COPY"
42
- REQUIRED_FIELDS = [
43
- "MONGODB_ATLAS_ORG_ID",
44
- "MONGODB_ATLAS_PRIVATE_KEY",
45
- "MONGODB_ATLAS_PUBLIC_KEY",
46
- ]
47
39
  FILENAME_ENV_MANUAL = ".env-manual"
48
40
  T = TypeVar("T")
49
41
 
50
42
 
51
- class ExternalSettings(AtlasSettings):
52
- model_config = SettingsConfigDict(env_prefix="", extra="ignore")
43
+ def read_from_env(env_key: str, default: str = "") -> str:
44
+ return next(
45
+ (os.environ[name] for name in [env_key, env_key.lower(), env_key.upper()] if name in os.environ),
46
+ default,
47
+ )
53
48
 
54
- TF_CLI_CONFIG_FILE: str = ""
55
- AWS_PROFILE: str = ""
56
- AWS_REGION: AwsRegion = "us-east-1"
57
- non_interactive: bool = False
58
49
 
59
- @property
60
- def realm_url(self) -> str:
61
- assert not self.is_mongodbgov_cloud, "realm_url is not supported for mongodbgov cloud"
62
- if "cloud-dev." in self.MONGODB_ATLAS_BASE_URL:
63
- return "https://services.cloud-dev.mongodb.com/"
64
- return "https://services.cloud.mongodb.com/"
50
+ class AtlasInitSettings(StaticSettings):
51
+ atlas_init_profile: str = DEFAULT_PROFILE # override this for different env, e.g. dev, prod
52
+ atlas_init_project_name: str = DEFAULT_PROJECT_NAME # used in the atlas cloud
53
+ atlas_init_config_path: Path = DEFAULT_ATLAS_INIT_CONFIG_PATH # /atlas_init.yaml
54
+ atlas_init_tf_src_path: Path = DEFAULT_TF_SRC_PATH # /tf directory of repo
55
+ atlas_init_tf_schema_config_path: Path = DEFAULT_ATLAS_INIT_SCHEMA_CONFIG_PATH # /terraform.yaml
56
+ atlas_init_schema_out_path: Path | None = None # override this for the generated schema
57
+
58
+ atlas_init_cfn_profile: str = ""
59
+ atlas_init_cfn_region: str = ""
60
+ atlas_init_cfn_use_kms_key: bool = False
61
+ atlas_init_cliboard_copy: str = ""
62
+ atlas_init_test_suites: str = ""
63
+ atlas_init_s3_profile_bucket: str = ""
64
+
65
+ non_interactive: bool = False
65
66
 
66
67
  @property
67
68
  def is_interactive(self) -> bool:
68
69
  return not self.non_interactive
69
70
 
70
71
  @property
71
- def is_mongodbgov_cloud(self) -> bool:
72
- return "mongodbgov" in self.MONGODB_ATLAS_BASE_URL
72
+ def profiles_path(self) -> Path:
73
+ return self.static_root / "profiles"
73
74
 
75
+ @property
76
+ def project_name(self) -> str:
77
+ return self.atlas_init_project_name
74
78
 
75
- def read_from_env(env_key: str, default: str = "") -> str:
76
- for name in [env_key, env_key.lower(), env_key.upper()]:
77
- if name in os.environ:
78
- return os.environ[name]
79
- return default
80
-
81
-
82
- class AtlasInitPaths(BaseSettings):
83
- model_config = SettingsConfigDict(env_prefix=ENV_PREFIX)
84
-
85
- profile: str = DEFAULT_PROFILE
86
- config_path: Path = DEFAULT_CONFIG_PATH
87
- tf_path: Path = DEFAULT_TF_PATH
88
- profiles_path: Path = DEFAULT_PROFILES_PATH
89
- tf_schema_config_path: Path = DEFAULT_SCHEMA_CONFIG_PATH
90
- schema_out_path: Path | None = None
91
- s3_profile_bucket: str = ""
79
+ @property
80
+ def profile(self) -> str:
81
+ return self.atlas_init_profile
92
82
 
93
83
  @property
94
84
  def schema_out_path_computed(self) -> Path:
95
- return self.schema_out_path or self.profile_dir / "schema"
85
+ return self.atlas_init_schema_out_path or self.static_root / "schema"
96
86
 
97
87
  @property
98
88
  def profile_dir(self) -> Path:
@@ -135,103 +125,42 @@ class AtlasInitPaths(BaseSettings):
135
125
  def tf_outputs_path(self) -> Path:
136
126
  return self.profile_dir / "tf_outputs.json"
137
127
 
138
- def load_env_vars(self, path: Path) -> dict[str, str]:
139
- return load_dotenv(path)
140
-
141
- def load_env_vars_full(self) -> dict[str, str]:
142
- env_path = self.env_vars_vs_code
143
- assert env_path.exists(), f"no env-vars exist {env_path} have you forgotten apply?"
144
- return load_dotenv(env_path)
145
-
146
- def env_vars_cls_or_none(self, t: type[T], *, path: Path | None = None) -> T | None:
147
- with suppress(ValidationError):
148
- return self.env_vars_cls(t, path=path)
149
-
150
- def env_vars_cls(self, t: type[T], *, path: Path | None = None) -> T:
151
- path = path or self.env_vars_vs_code
152
- env_vars = self.load_env_vars(path) if path.exists() else {}
153
- return t(**env_vars)
154
-
155
- def load_profile_manual_env_vars(self, *, skip_os_update: bool = False) -> dict[str, str]:
156
- # sourcery skip: dict-assign-update-to-union
157
- manual_env_vars = self.manual_env_vars
158
- if manual_env_vars:
159
- if skip_os_update:
160
- return manual_env_vars
161
- if new_updates := {k: v for k, v in manual_env_vars.items() if k not in os.environ}:
162
- logger.info(f"loading manual env-vars {','.join(new_updates)}")
163
- os.environ.update(new_updates)
164
- else:
165
- logger.warning(f"no {self.env_file_manual} exists")
166
- return manual_env_vars
128
+ @property
129
+ def github_ci_run_logs(self) -> Path:
130
+ return self.cache_root / "github_ci_run_logs"
131
+
132
+ @property
133
+ def github_ci_summary_dir(self) -> Path:
134
+ return self.cache_root / "github_ci_summary"
135
+
136
+ @property
137
+ def go_test_logs_dir(self) -> Path:
138
+ return self.cache_root / "go_test_logs"
139
+
140
+ @property
141
+ def atlas_atlas_api_transformed_yaml(self) -> Path:
142
+ return self.cache_root / "atlas_api_transformed.yaml"
143
+
144
+ def cfn_region(self, default: str) -> str:
145
+ return self.atlas_init_cfn_region or default
167
146
 
168
147
  def include_extra_env_vars_in_vscode(self, extra_env_vars: dict[str, str]) -> None:
169
148
  extra_name = ", ".join(extra_env_vars.keys())
170
- original_env_vars = self.load_env_vars(self.env_vars_vs_code)
149
+ original_env_vars = load_dotenv(self.env_vars_vs_code)
171
150
  new_env_vars = original_env_vars | extra_env_vars
172
151
  dump_dotenv(self.env_vars_vs_code, new_env_vars)
173
152
  logger.info(f"done {self.env_vars_vs_code} updated with {extra_name} env-vars ✅")
174
153
 
175
-
176
- class EnvVarsCheck(NamedTuple):
177
- missing: list[str]
178
- ambiguous: list[str]
179
-
180
-
181
- class AtlasInitSettings(AtlasInitPaths, ExternalSettings):
182
- model_config = SettingsConfigDict(env_prefix=ENV_PREFIX)
183
-
184
- cfn_profile: str = ""
185
- cfn_region: str = ""
186
- cfn_use_kms_key: bool = False
187
- project_name: str = ""
188
-
189
- cliboard_copy: str = ""
190
- test_suites: str = ""
191
-
192
- @classmethod
193
- def check_env_vars(
194
- cls,
195
- profile: str = DEFAULT_PROFILE,
196
- required_env_vars: list[str] | None = None,
197
- ) -> EnvVarsCheck:
198
- required_env_vars = required_env_vars or []
199
- path_settings = AtlasInitPaths(profile=profile)
200
- manual_env_vars = path_settings.manual_env_vars
201
- ambiguous: list[str] = []
202
- for env_name, manual_value in manual_env_vars.items():
203
- env_value = read_from_env(env_name)
204
- if env_value and manual_value != env_value:
205
- ambiguous.append(env_name)
206
- missing_env_vars = sorted(
207
- env_name
208
- for env_name in REQUIRED_FIELDS + required_env_vars
209
- if read_from_env(env_name) == "" and env_name not in manual_env_vars
210
- )
211
- return EnvVarsCheck(missing=missing_env_vars, ambiguous=sorted(ambiguous))
212
-
213
- @classmethod
214
- def safe_settings(cls, profile: str, *, ext_settings: ExternalSettings | None = None) -> AtlasInitSettings:
215
- """side effect of loading manual env-vars and set profile"""
216
- os.environ[ENV_PROFILE] = profile
217
- AtlasInitPaths(profile=profile).load_profile_manual_env_vars()
218
- ext_settings = ext_settings or ExternalSettings() # type: ignore
219
- path_settings = AtlasInitPaths()
220
- return cls(**path_settings.model_dump(), **ext_settings.model_dump())
221
-
222
- @field_validator("test_suites", mode="after")
154
+ @field_validator(ENV_TEST_SUITES.lower(), mode="after")
223
155
  @classmethod
224
156
  def ensure_whitespace_replaced_with_commas(cls, value: str) -> str:
225
157
  return value.strip().replace(" ", ",")
226
158
 
227
- @model_validator(mode="after")
228
- def post_init(self):
229
- self.cfn_region = self.cfn_region or self.AWS_REGION
230
- return self
231
-
232
159
  @cached_property
233
160
  def config(self) -> AtlasInitConfig:
234
- config_path = Path(self.config_path) if self.config_path else DEFAULT_CONFIG_PATH
161
+ config_path = (
162
+ Path(self.atlas_init_config_path) if self.atlas_init_config_path else DEFAULT_ATLAS_INIT_CONFIG_PATH
163
+ )
235
164
  assert config_path.exists(), f"no config path found @ {config_path}"
236
165
  yaml_parsed = parse_payload(config_path)
237
166
  assert isinstance(yaml_parsed, dict), f"config must be a dictionary, got {yaml_parsed}"
@@ -239,30 +168,35 @@ class AtlasInitSettings(AtlasInitPaths, ExternalSettings):
239
168
 
240
169
  @property
241
170
  def test_suites_parsed(self) -> list[str]:
242
- return [t for t in self.test_suites.split(",") if t]
171
+ return [t for t in self.atlas_init_test_suites.split(",") if t]
243
172
 
244
- def tf_vars(self) -> dict[str, Any]:
173
+ def tf_vars(self, default_aws_region: str) -> dict[str, Any]:
245
174
  variables = {}
246
- if self.cfn_profile:
175
+ if self.atlas_init_cfn_profile:
247
176
  variables["cfn_config"] = {
248
- "profile": self.cfn_profile,
249
- "region": self.cfn_region,
250
- "use_kms_key": self.cfn_use_kms_key,
177
+ "profile": self.atlas_init_cfn_profile,
178
+ "region": self.atlas_init_cfn_region or default_aws_region,
179
+ "use_kms_key": self.atlas_init_cfn_use_kms_key,
251
180
  }
252
- if self.s3_profile_bucket:
181
+ if self.atlas_init_s3_profile_bucket:
253
182
  variables["use_aws_s3"] = True
254
183
  return variables
255
184
 
256
185
 
186
+ class EnvVarsCheck(NamedTuple):
187
+ missing: list[str]
188
+ ambiguous: list[str]
189
+
190
+ @property
191
+ def is_ok(self) -> bool:
192
+ return not self.missing and not self.ambiguous
193
+
194
+
257
195
  def active_suites(settings: AtlasInitSettings) -> list[TestSuite]: # type: ignore
258
196
  repo_path, cwd_rel_path = repo_path_rel_path()
259
197
  return config_active_suites(settings.config, repo_path, cwd_rel_path, settings.test_suites_parsed)
260
198
 
261
199
 
262
- _sentinel = object()
263
- PLACEHOLDER_VALUE = "PLACEHOLDER"
264
-
265
-
266
200
  class EnvVarsError(Exception):
267
201
  def __init__(self, missing: list[str], ambiguous: list[str]):
268
202
  self.missing = missing
@@ -273,31 +207,61 @@ class EnvVarsError(Exception):
273
207
  return f"missing: {self.missing}, ambiguous: {self.ambiguous}"
274
208
 
275
209
 
210
+ def collect_required_env_vars(settings_classes: list[type[BaseModel]]) -> list[str]:
211
+ cls_required_env_vars: dict[str, list[str]] = {}
212
+ for cls in settings_classes:
213
+ try:
214
+ cls()
215
+ except ValidationError as error:
216
+ cls_required_env_vars[cls.__name__] = [".".join(str(loc) for loc in e["loc"]) for e in error.errors()]
217
+ return list(iter_utils.flat_map(cls_required_env_vars.values()))
218
+
219
+
220
+ def detect_ambiguous_env_vars(manual_env_vars: dict[str, str]) -> list[str]:
221
+ ambiguous: list[str] = []
222
+ for env_name, manual_value in manual_env_vars.items():
223
+ env_value = read_from_env(env_name)
224
+ if env_value and manual_value != env_value:
225
+ ambiguous.append(env_name)
226
+ return ambiguous
227
+
228
+
229
+ def find_missing_env_vars(required_env_vars: list[str], manual_env_vars: dict[str, str]) -> list[str]:
230
+ return sorted(
231
+ env_name for env_name in required_env_vars if read_from_env(env_name) == "" and env_name not in manual_env_vars
232
+ )
233
+
234
+
276
235
  def init_settings(
277
- required_env_vars: list[str] | object = _sentinel,
278
- *,
279
- non_required: bool = False,
236
+ *settings_classes: type[BaseModel],
280
237
  ) -> AtlasInitSettings:
281
- if required_env_vars is _sentinel:
282
- required_env_vars = [ENV_PROJECT_NAME]
283
- if non_required:
284
- required_env_vars = []
285
- profile = os.getenv("ATLAS_INIT_PROFILE", DEFAULT_PROFILE)
286
- missing_env_vars, ambiguous_env_vars = AtlasInitSettings.check_env_vars(
287
- profile,
288
- required_env_vars=required_env_vars, # type: ignore
289
- )
290
- if missing_env_vars and not non_required:
291
- logger.warning(f"missing env_vars: {missing_env_vars}")
292
- if ambiguous_env_vars:
238
+ settings = AtlasInitSettings.from_env()
239
+ profile_env_vars = settings.manual_env_vars
240
+ vscode_env_vars = settings.env_vars_vs_code
241
+ if vscode_env_vars.exists():
242
+ profile_env_vars |= load_dotenv(vscode_env_vars)
243
+ required_env_vars = collect_required_env_vars(list(settings_classes))
244
+ ambiguous = detect_ambiguous_env_vars(profile_env_vars)
245
+ missing_env_vars = find_missing_env_vars(required_env_vars, profile_env_vars)
246
+
247
+ if ambiguous:
293
248
  logger.warning(
294
- f"amiguous env_vars: {ambiguous_env_vars} (specified both in cli/env & in .env-manual file with different values)"
249
+ f"ambiguous env_vars: {ambiguous} (specified both in cli/env & in .env-(manual|vscode) file with different values)"
295
250
  )
296
- ext_settings = None
297
- if non_required and missing_env_vars:
298
- placeholders = {k: PLACEHOLDER_VALUE for k in missing_env_vars}
299
- missing_env_vars = []
300
- ext_settings = ExternalSettings(**placeholders) # type: ignore
301
- if missing_env_vars or ambiguous_env_vars:
302
- raise EnvVarsError(missing_env_vars, ambiguous_env_vars)
303
- return AtlasInitSettings.safe_settings(profile, ext_settings=ext_settings)
251
+ if missing_env_vars or ambiguous:
252
+ raise EnvVarsError(missing_env_vars, ambiguous)
253
+
254
+ if new_updates := {k: v for k, v in profile_env_vars.items() if k not in os.environ}:
255
+ logger.info(f"loading env-vars {','.join(sorted(new_updates))}")
256
+ os.environ |= new_updates
257
+ for cls in settings_classes:
258
+ cls() # ensure any errors are raised
259
+ return AtlasInitSettings.from_env()
260
+
261
+
262
+ def env_vars_cls_or_none(t: type[T], *, dotenv_path: Path | None = None) -> T | None:
263
+ explicit_vars: dict[str, str] = {}
264
+ if dotenv_path and dotenv_path.exists():
265
+ explicit_vars = load_dotenv(dotenv_path)
266
+ with suppress(ValidationError):
267
+ return t(**explicit_vars)
@@ -1,18 +1,55 @@
1
1
  import random
2
+ from typing import Self
2
3
 
3
- from pydantic import ConfigDict, Field
4
+ from pydantic import ConfigDict, Field, model_validator
4
5
  from pydantic_settings import BaseSettings
5
6
 
6
7
 
7
8
  class _EnvVarsGenerated(BaseSettings):
8
9
  model_config = ConfigDict(extra="ignore") # type: ignore
9
10
 
11
+ @classmethod
12
+ def from_env(cls) -> Self:
13
+ return cls()
14
+
10
15
 
11
16
  class AtlasSettings(_EnvVarsGenerated):
12
17
  MONGODB_ATLAS_ORG_ID: str
13
18
  MONGODB_ATLAS_PRIVATE_KEY: str
14
19
  MONGODB_ATLAS_PUBLIC_KEY: str
15
- MONGODB_ATLAS_BASE_URL: str = "https://cloud-dev.mongodb.com/"
20
+ MONGODB_ATLAS_BASE_URL: str
21
+
22
+ @property
23
+ def realm_url(self) -> str:
24
+ assert not self.is_mongodbgov_cloud, "realm_url is not supported for mongodbgov cloud"
25
+ if "cloud-dev." in self.MONGODB_ATLAS_BASE_URL:
26
+ return "https://services.cloud-dev.mongodb.com/"
27
+ return "https://services.cloud.mongodb.com/"
28
+
29
+ @property
30
+ def is_mongodbgov_cloud(self) -> bool:
31
+ return "mongodbgov" in self.MONGODB_ATLAS_BASE_URL
32
+
33
+
34
+ class AWSSettings(_EnvVarsGenerated):
35
+ AWS_REGION: str
36
+ AWS_PROFILE: str = ""
37
+ AWS_ACCESS_KEY_ID: str = ""
38
+ AWS_SECRET_ACCESS_KEY: str = ""
39
+
40
+ @model_validator(mode="after")
41
+ def ensure_credentials_are_given(self) -> Self:
42
+ assert self.AWS_PROFILE or (self.AWS_ACCESS_KEY_ID and self.AWS_SECRET_ACCESS_KEY), (
43
+ "Either AWS_PROFILE or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be provided"
44
+ )
45
+ assert not (self.AWS_PROFILE and (self.AWS_ACCESS_KEY_ID and self.AWS_SECRET_ACCESS_KEY)), (
46
+ "Either AWS_PROFILE or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be provided, not both"
47
+ )
48
+ return self
49
+
50
+
51
+ class TerraformSettings(_EnvVarsGenerated):
52
+ TF_CLI_CONFIG_FILE: str
16
53
 
17
54
 
18
55
  class RealmSettings(_EnvVarsGenerated):
@@ -24,11 +61,5 @@ class RealmSettings(_EnvVarsGenerated):
24
61
  RANDOM_INT_100K: str = Field(default_factory=lambda: str(random.randint(0, 100_000))) # noqa: S311 # not used for cryptographic purposes # nosec
25
62
 
26
63
 
27
- class EnvVarsGenerated(AtlasSettings):
64
+ class AtlasSettingsWithProject(AtlasSettings):
28
65
  MONGODB_ATLAS_PROJECT_ID: str
29
-
30
-
31
- class TFModuleCluster(_EnvVarsGenerated):
32
- MONGODB_ATLAS_CLUSTER_NAME: str
33
- MONGODB_ATLAS_CONTAINER_ID: str
34
- MONGODB_URL: str