atlas-init 0.7.0__py3-none-any.whl → 0.8.1__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 (33) hide show
  1. atlas_init/__init__.py +1 -1
  2. atlas_init/atlas_init.yaml +1 -0
  3. atlas_init/cli_tf/example_update.py +20 -8
  4. atlas_init/cli_tf/hcl/modifier.py +22 -8
  5. atlas_init/settings/env_vars.py +12 -2
  6. atlas_init/tf_ext/api_call.py +9 -9
  7. atlas_init/tf_ext/args.py +16 -1
  8. atlas_init/tf_ext/gen_examples.py +141 -0
  9. atlas_init/tf_ext/gen_module_readme.py +131 -0
  10. atlas_init/tf_ext/gen_resource_main.py +195 -0
  11. atlas_init/tf_ext/gen_resource_output.py +71 -0
  12. atlas_init/tf_ext/gen_resource_variables.py +162 -0
  13. atlas_init/tf_ext/gen_versions.py +10 -0
  14. atlas_init/tf_ext/models_module.py +455 -0
  15. atlas_init/tf_ext/newres.py +90 -0
  16. atlas_init/tf_ext/plan_diffs.py +140 -0
  17. atlas_init/tf_ext/provider_schema.py +199 -0
  18. atlas_init/tf_ext/py_gen.py +294 -0
  19. atlas_init/tf_ext/schema_to_dataclass.py +522 -0
  20. atlas_init/tf_ext/settings.py +151 -2
  21. atlas_init/tf_ext/tf_dep.py +5 -5
  22. atlas_init/tf_ext/tf_desc_gen.py +53 -0
  23. atlas_init/tf_ext/tf_desc_update.py +0 -0
  24. atlas_init/tf_ext/tf_mod_gen.py +263 -0
  25. atlas_init/tf_ext/tf_mod_gen_provider.py +124 -0
  26. atlas_init/tf_ext/tf_modules.py +5 -4
  27. atlas_init/tf_ext/tf_vars.py +13 -28
  28. atlas_init/tf_ext/typer_app.py +6 -2
  29. {atlas_init-0.7.0.dist-info → atlas_init-0.8.1.dist-info}/METADATA +4 -3
  30. {atlas_init-0.7.0.dist-info → atlas_init-0.8.1.dist-info}/RECORD +33 -17
  31. {atlas_init-0.7.0.dist-info → atlas_init-0.8.1.dist-info}/WHEEL +0 -0
  32. {atlas_init-0.7.0.dist-info → atlas_init-0.8.1.dist-info}/entry_points.txt +0 -0
  33. {atlas_init-0.7.0.dist-info → atlas_init-0.8.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,195 @@
1
+ import logging
2
+ from dataclasses import fields
3
+ from pathlib import Path
4
+ from tempfile import TemporaryDirectory
5
+ from typing import Iterable
6
+
7
+ from ask_shell import run_and_wait
8
+
9
+ from atlas_init.tf_ext.models_module import ModuleGenConfig, ResourceAbs, ResourceGenConfig
10
+ from atlas_init.tf_ext.schema_to_dataclass import ResourceTypePythonModule
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def local_name_varsx(resource_type: str) -> str:
16
+ return f"{resource_type}_varsx"
17
+
18
+
19
+ def local_name_vars(resource_type: str) -> str:
20
+ return f"{resource_type}_vars"
21
+
22
+
23
+ def locals_def(module: ResourceTypePythonModule) -> str:
24
+ base_defs = "\n".join(f" {name} = var.{name}" for name in module.base_field_names_not_computed)
25
+ if extras := module.extra_fields_names:
26
+ extra_defs = "\n".join(f" {name} = var.{name}" for name in extras)
27
+ base_def = f" {local_name_varsx(module.resource_type)} = {{\n{base_defs}\n }}"
28
+ extra_def = f"\n {local_name_vars(module.resource_type)} = {{\n{extra_defs}\n }}"
29
+ else:
30
+ base_def = f" {local_name_vars(module.resource_type)} = {{\n{base_defs}\n }}"
31
+ extra_def = ""
32
+ return f"""
33
+ locals {{
34
+ {base_def}{extra_def}
35
+ }}
36
+ """
37
+
38
+
39
+ def data_external(module: ResourceTypePythonModule, config: ModuleGenConfig) -> str:
40
+ input_json_parts = [
41
+ f"local.{local_name_vars(module.resource_type)}",
42
+ ]
43
+ if module.extra_fields_names:
44
+ input_json_parts.append(f"local.{local_name_varsx(module.resource_type)}")
45
+ if extras := config.inputs_json_hcl_extras:
46
+ input_json_parts.extend(extras)
47
+ inputs_json_merge = input_json_parts[0] if len(input_json_parts) == 1 else f"merge({', '.join(input_json_parts)})"
48
+ return f"""
49
+ data "external" "{module.resource_type}" {{
50
+ program = ["python3", "${{path.module}}/{module.resource_type}.py"]
51
+ query = {{
52
+ input_json = jsonencode({inputs_json_merge})
53
+ }}
54
+ }}
55
+ """
56
+
57
+
58
+ def resource_declare_direct(py_module: ResourceTypePythonModule, config: ResourceGenConfig) -> str:
59
+ parent_cls = py_module.resource
60
+ resource_type = py_module.resource_type
61
+ assert parent_cls, f"{resource_type} does not have a resource"
62
+ field_base = f"var.{resource_type}." if config.use_single_variable else "var."
63
+ field_values = "\n".join(
64
+ _field_value(parent_cls, name, field_base) for name in py_module.base_field_names_not_computed
65
+ )
66
+
67
+ return f"""
68
+ resource "{py_module.resource_type}" "this" {{
69
+ {field_values}
70
+ }}
71
+ """
72
+
73
+
74
+ def _field_value(parent_cls: type[ResourceAbs], field_name: str, field_base: str = "var.") -> str:
75
+ if ResourceAbs.is_computed_only(field_name, parent_cls):
76
+ return ""
77
+ this_indent = " "
78
+ if ResourceAbs.is_block(field_name, parent_cls):
79
+ return "\n".join(f"{this_indent}{line}" for line in _handle_dynamic(parent_cls, field_name, field_base))
80
+ return this_indent + f"{field_name} = {field_base}{field_name}"
81
+
82
+
83
+ def _handle_dynamic(
84
+ parent_cls: type[ResourceAbs], dynamic_field_name: str, existing_ref: str = "var."
85
+ ) -> Iterable[str]:
86
+ try:
87
+ container_type = next(
88
+ t for name, t in ResourceTypePythonModule.container_types(parent_cls) if name == dynamic_field_name
89
+ )
90
+ except StopIteration:
91
+ raise ValueError(f"Could not find container type for field {dynamic_field_name} in {parent_cls}")
92
+ hcl_ref = f"{dynamic_field_name}.value."
93
+ yield f'dynamic "{dynamic_field_name}" {{'
94
+ ref = existing_ref + dynamic_field_name
95
+ if container_type.is_list or container_type.is_set:
96
+ if container_type.is_optional:
97
+ yield f" for_each = {ref} == null ? [] : {ref}"
98
+ else:
99
+ yield f" for_each = {ref}"
100
+ elif container_type.is_dict:
101
+ raise NotImplementedError(f"Dict not supported for {dynamic_field_name} in {parent_cls}")
102
+ else: # singular
103
+ if container_type.is_optional:
104
+ yield f" for_each = {ref} == null ? [] : [{ref}]"
105
+ else:
106
+ yield f" for_each = [{ref}]"
107
+ yield " content {"
108
+ yield from [f" {line}" for line in _nested_fields(container_type.type, hcl_ref)]
109
+ yield " }"
110
+ yield "}"
111
+
112
+
113
+ def _nested_fields(cls: type[ResourceAbs], hcl_ref: str) -> Iterable[str]:
114
+ for field in fields(cls):
115
+ field_name = field.name
116
+ if ResourceAbs.is_computed_only(field_name, cls):
117
+ continue
118
+ if ResourceAbs.is_block(field_name, cls):
119
+ yield from _handle_dynamic(cls, field_name, hcl_ref)
120
+ else:
121
+ yield _field_value(cls, field_name, hcl_ref)
122
+
123
+
124
+ def resource_declare(
125
+ resource_type: str, required_fields: set[str], nested_fields: set[str], field_names: list[str]
126
+ ) -> str:
127
+ def output_field(field_name: str) -> str:
128
+ return f"data.external.{resource_type}.result.{field_name}"
129
+
130
+ def as_output_field(field_name: str) -> str:
131
+ if field_name in nested_fields:
132
+ if field_name in required_fields:
133
+ return f"jsondecode({output_field(field_name)})"
134
+ return f'{output_field(field_name)} == "" ? null : jsondecode({output_field(field_name)})'
135
+ if field_name in required_fields:
136
+ return output_field(field_name)
137
+ return f'{output_field(field_name)} == "" ? null : {output_field(field_name)}'
138
+
139
+ required = [f" {field_name} = {as_output_field(field_name)}" for field_name in sorted(required_fields)]
140
+ non_required = [
141
+ f" {field_name} = {as_output_field(field_name)}"
142
+ for field_name in sorted(field_names)
143
+ if field_name not in required_fields
144
+ ]
145
+ return f"""
146
+ resource "{resource_type}" "this" {{
147
+ lifecycle {{
148
+ precondition {{
149
+ condition = length({output_field("error_message")}) == 0
150
+ error_message = {output_field("error_message")}
151
+ }}
152
+ }}
153
+
154
+ {"\n".join(required)}
155
+ {"\n".join(non_required)}
156
+ }}
157
+ """
158
+
159
+
160
+ def format_tf_content(content: str) -> str:
161
+ with TemporaryDirectory() as tmp_dir:
162
+ tmp_file = Path(tmp_dir) / "content.tf"
163
+ tmp_file.write_text(content)
164
+ try:
165
+ run_and_wait("terraform fmt .", cwd=tmp_dir)
166
+ except Exception as e:
167
+ logger.error(f"Failed to format tf content:\n{content}")
168
+ raise e
169
+ return tmp_file.read_text()
170
+
171
+
172
+ def generate_resource_main(python_module: ResourceTypePythonModule, config: ModuleGenConfig) -> str:
173
+ resource = python_module.resource_ext or python_module.resource
174
+ assert resource, f"{python_module} does not have a resource"
175
+ resource_hcl = (
176
+ resource_declare_direct(python_module, config.resource_config(python_module.resource_type))
177
+ if config.skip_python
178
+ else resource_declare(
179
+ resource_type=python_module.resource_type,
180
+ required_fields=resource.REQUIRED_ATTRIBUTES,
181
+ nested_fields=resource.NESTED_ATTRIBUTES,
182
+ field_names=python_module.base_field_names_not_computed,
183
+ )
184
+ )
185
+ return format_tf_content(
186
+ "\n".join(
187
+ [
188
+ *([] if config.skip_python else [locals_def(python_module)]),
189
+ *([] if config.skip_python else [data_external(python_module, config)]),
190
+ "",
191
+ resource_hcl,
192
+ "",
193
+ ]
194
+ )
195
+ )
@@ -0,0 +1,71 @@
1
+ from dataclasses import fields
2
+ from typing import Iterable
3
+ from atlas_init.tf_ext.models_module import ContainerType, ResourceTypePythonModule, ModuleGenConfig, ResourceAbs
4
+
5
+
6
+ def as_output(resource_type: str, field_name: str, output_name: str) -> str:
7
+ return _as_output(output_name, f"{resource_type}.this.{field_name}")
8
+
9
+
10
+ def _as_output(name: str, value: str) -> str:
11
+ return f"""\
12
+ output "{name}" {{
13
+ value = {value}
14
+ }}
15
+ """
16
+
17
+
18
+ def as_nested_output(
19
+ resource_type: str,
20
+ parent_cls: type[ResourceAbs],
21
+ nested_types: dict[str, ContainerType[ResourceAbs]],
22
+ config: ModuleGenConfig,
23
+ ) -> Iterable[str]:
24
+ resource_id = f"{resource_type}.this"
25
+ for field_name, container_type in nested_types.items():
26
+ if container_type.is_any:
27
+ continue
28
+ computed_nested_fields = [
29
+ nested_field.name
30
+ for nested_field in fields(container_type.type)
31
+ if ResourceAbs.is_computed_only(nested_field.name, container_type.type)
32
+ ]
33
+ if container_type.is_list:
34
+ for computed_field_name in computed_nested_fields:
35
+ if container_type.is_optional and not ResourceAbs.is_required(field_name, parent_cls):
36
+ yield _as_output(
37
+ config.output_name(resource_type, field_name, computed_field_name),
38
+ f"{resource_id}.{field_name} == null ? null : {resource_id}.{field_name}[*].{computed_field_name}",
39
+ )
40
+ else:
41
+ yield _as_output(
42
+ config.output_name(resource_type, field_name, computed_field_name),
43
+ f"{resource_id}.{field_name}[*].{computed_field_name}",
44
+ )
45
+ elif container_type.is_set:
46
+ continue # block type "limits" is represented by a set of objects, and set elements do not have addressable keys. To find elements matching specific criteria, use a "for" expression with an "if" clause.
47
+ elif container_type.is_dict or container_type.is_set:
48
+ raise NotImplementedError("Dict and set container types not supported yet")
49
+ else:
50
+ for computed_field_name in computed_nested_fields:
51
+ if container_type.is_optional and not ResourceAbs.is_required(field_name, parent_cls):
52
+ yield _as_output(
53
+ config.output_name(resource_type, field_name, computed_field_name),
54
+ f"{resource_id}.{field_name} == null ? null : {resource_id}.{field_name}.{computed_field_name}",
55
+ )
56
+ else:
57
+ yield _as_output(
58
+ config.output_name(resource_type, field_name, computed_field_name),
59
+ f"{resource_id}.{field_name}.{computed_field_name}",
60
+ )
61
+
62
+
63
+ def generate_resource_output(py_module: ResourceTypePythonModule, config: ModuleGenConfig) -> str:
64
+ nested_types = dict(py_module.nested_field_types)
65
+ base_resource = py_module.resource
66
+ assert base_resource is not None, f"Resource {py_module.resource_type} has no base resource"
67
+ computed_field_names = [name for name in py_module.base_field_names_computed if name not in nested_types]
68
+ return "\n".join(
69
+ as_output(py_module.resource_type, field_name, config.output_name(py_module.resource_type, field_name))
70
+ for field_name in computed_field_names
71
+ ) + "\n".join(as_nested_output(py_module.resource_type, base_resource, nested_types, config))
@@ -0,0 +1,162 @@
1
+ import logging
2
+ from contextlib import contextmanager
3
+ from dataclasses import Field, fields, is_dataclass
4
+ from typing import Dict, List, Set, Union, get_args, get_origin, get_type_hints
5
+
6
+ from model_lib import Entity
7
+ from pydantic import Field as PydanticField
8
+
9
+ from atlas_init.tf_ext.gen_resource_main import format_tf_content
10
+ from atlas_init.tf_ext.models_module import ResourceAbs, ResourceGenConfig, ResourceTypePythonModule
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class DefaultValueContext(Entity):
16
+ default_lines: list[str] = PydanticField(default_factory=list)
17
+ field_path: list[str] = PydanticField(default_factory=list)
18
+ ignored_names: set[str] = PydanticField(default_factory=set)
19
+
20
+ @property
21
+ def final_str(self) -> str:
22
+ if not self.default_lines:
23
+ return "null"
24
+ return "\n".join(self.default_lines)
25
+
26
+ @property
27
+ def current_field_path(self) -> str:
28
+ return ".".join(self.field_path)
29
+
30
+ def at_root(self, field_name: str) -> bool:
31
+ return field_name == self.current_field_path
32
+
33
+ @property
34
+ def _prefix(self) -> str:
35
+ return " " * len(self.field_path)
36
+
37
+ @contextmanager
38
+ def add_nested_field(self, field_name: str):
39
+ if not self.at_root(field_name):
40
+ self.field_path.append(field_name)
41
+ default_index_before = len(self.default_lines)
42
+ try:
43
+ yield
44
+ default_index_after = len(self.default_lines)
45
+ if (
46
+ default_index_before == default_index_after
47
+ ): # no child had a default, so we don't need to add default lines
48
+ return
49
+ if self.at_root(field_name):
50
+ self.default_lines.insert(default_index_before, "{")
51
+ else:
52
+ self.default_lines.insert(default_index_before, f"{self._prefix}{field_name} = {{")
53
+ self.default_lines.append(f"{self._prefix}}}")
54
+ finally:
55
+ self.field_path.pop()
56
+
57
+ def add_default(self, field_name: str, default_value: str) -> None:
58
+ if self.at_root(field_name): # root field default value, no field name needed
59
+ self.default_lines.append(default_value)
60
+ else:
61
+ self.default_lines.append(f"{self._prefix}{field_name} = {default_value}")
62
+
63
+
64
+ def python_type_to_terraform_type(field: Field, py_type: type, context: DefaultValueContext) -> str:
65
+ # Unwrap Optional/Union
66
+ origin = get_origin(py_type)
67
+ args = get_args(py_type)
68
+ if origin is Union and type(None) in args:
69
+ # Optional[X] or Union[X, None] -> X
70
+ not_none = [a for a in args if a is not type(None)]
71
+ return python_type_to_terraform_type(field, not_none[0], context) if not_none else "any"
72
+ if origin is list or origin is List:
73
+ elem_type = python_type_to_terraform_type(field, args[0], context)
74
+ return f"list({elem_type})"
75
+ elif origin is set or origin is Set:
76
+ elem_type = python_type_to_terraform_type(field, args[0], context)
77
+ return f"set({elem_type})"
78
+ elif origin is dict or origin is Dict:
79
+ elem_type = python_type_to_terraform_type(field, args[1], context)
80
+ return f"map({elem_type})"
81
+ elif is_dataclass(py_type):
82
+ return dataclass_to_object_type(field.name, py_type, context)
83
+ elif py_type is str:
84
+ return "string"
85
+ elif py_type is int or py_type is float:
86
+ return "number"
87
+ elif py_type is bool:
88
+ return "bool"
89
+ else:
90
+ return "any"
91
+
92
+
93
+ def dataclass_to_object_type(name: str, cls: type, context: DefaultValueContext) -> str:
94
+ lines = ["object({"]
95
+ hints = get_type_hints(cls)
96
+ with context.add_nested_field(name):
97
+ for f in fields(cls):
98
+ # Skip ClassVars and internal fields
99
+ nested_field_name = f.name
100
+ is_computed_only = ResourceAbs.is_computed_only(nested_field_name, cls)
101
+ if is_computed_only or nested_field_name in context.ignored_names:
102
+ continue
103
+ tf_type = python_type_to_terraform_type(f, hints[nested_field_name], context)
104
+ is_required = ResourceAbs.is_required(nested_field_name, cls)
105
+ if default_value := ResourceAbs.default_hcl_string(nested_field_name, cls):
106
+ context.add_default(nested_field_name, default_value)
107
+ lines.append(f" {nested_field_name} = optional({tf_type}, {default_value})")
108
+ elif is_required:
109
+ lines.append(f" {nested_field_name} = {tf_type}")
110
+ else:
111
+ lines.append(f" {nested_field_name} = optional({tf_type})")
112
+ lines.append("})")
113
+ return "\n".join(lines)
114
+
115
+
116
+ def generate_module_variables(
117
+ python_module: ResourceTypePythonModule, resource_config: ResourceGenConfig
118
+ ) -> tuple[str, str]:
119
+ base_resource = python_module.resource
120
+ assert base_resource is not None, f"{python_module} does not have a resource"
121
+ skipped_names_in_resource_ext = set(python_module.base_field_names)
122
+ return generate_resource_variables(base_resource, resource_config), generate_resource_variables(
123
+ python_module.resource_ext, resource_config, skipped_names_in_resource_ext
124
+ )
125
+
126
+
127
+ def generate_resource_variables(
128
+ resource: type[ResourceAbs] | None, resource_config: ResourceGenConfig, extra_skipped: set[str] | None = None
129
+ ) -> str:
130
+ extra_skipped = extra_skipped or set()
131
+ if resource is None:
132
+ return ""
133
+ required_variables = set(resource_config.required_variables)
134
+ if not resource_config.use_opt_in_required_variables:
135
+ required_variables |= getattr(resource, ResourceAbs.REQUIRED_ATTRIBUTES_NAME, set())
136
+ out = []
137
+ hints = get_type_hints(resource)
138
+ ignored_names = (
139
+ resource_config.skip_variables_extra
140
+ | resource.COMPUTED_ONLY_ATTRIBUTES
141
+ | getattr(resource, ResourceAbs.SKIP_VARIABLES_NAME, set())
142
+ | extra_skipped
143
+ )
144
+ if resource_config.use_single_variable:
145
+ context = DefaultValueContext(field_path=[], ignored_names=ignored_names)
146
+ tf_type = dataclass_to_object_type(resource_config.name, resource, context)
147
+ return format_tf_content(f'''variable "{resource_config.name}" {{
148
+ type = {tf_type}
149
+ }}\n''')
150
+ fields_sorted = sorted(fields(resource), key=lambda f: (0 if f.name in required_variables else 1, f.name))
151
+ for f in fields_sorted: # type: ignore
152
+ field_name = f.name
153
+ if field_name.isupper() or field_name in ignored_names:
154
+ continue
155
+ context = DefaultValueContext(field_path=[field_name])
156
+ tf_type = python_type_to_terraform_type(f, hints[field_name], context)
157
+ default_line = f"\n default = {context.final_str}" if field_name not in required_variables else ""
158
+ nullable_line = "\n nullable = true" if field_name not in required_variables else ""
159
+ out.append(f'''variable "{field_name}" {{
160
+ type = {tf_type}{nullable_line}{default_line}
161
+ }}\n''')
162
+ return format_tf_content("\n".join(out))
@@ -0,0 +1,10 @@
1
+ from pathlib import Path
2
+ from atlas_init.tf_ext.provider_schema import get_providers_tf
3
+ from zero_3rdparty.file_utils import ensure_parents_write_text
4
+
5
+
6
+ def dump_versions_tf(module_path: Path, skip_python: bool = False, minimal: bool = False) -> Path:
7
+ provider_path = module_path / "versions.tf"
8
+ if not provider_path.exists():
9
+ ensure_parents_write_text(provider_path, get_providers_tf(skip_python=skip_python, minimal=minimal))
10
+ return provider_path