atlas-init 0.8.1__py3-none-any.whl → 0.10.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.
@@ -1,16 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from functools import total_ordering
3
4
  import logging
4
5
  from collections import defaultdict
5
6
  from pathlib import Path
6
- from typing import Iterable, NamedTuple
7
+ from threading import RLock
8
+ from typing import Callable, Iterable, NamedTuple
7
9
 
8
10
  import pydot
9
11
  from ask_shell import ShellError, new_task, run_and_wait
10
12
  from ask_shell._run import stop_runs_and_pool
11
13
  from ask_shell.run_pool import run_pool
12
14
  from model_lib import Entity, dump
13
- from pydantic import BaseModel, Field
15
+ from pydantic import BaseModel, Field, model_validator
14
16
  from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
15
17
  from typer import Typer
16
18
  from zero_3rdparty.file_utils import ensure_parents_write_text
@@ -57,13 +59,25 @@ def tf_dep_graph(
57
59
  example_dirs = get_example_directories(repo_path, skip_names)
58
60
  logger.info(f"example_dirs: \n{'\n'.join(str(d) for d in sorted(example_dirs))}")
59
61
  with new_task("Find terraform graphs", total=len(example_dirs)) as task:
60
- atlas_graph = parse_graphs(example_dirs, task)
62
+ atlas_graph = create_atlas_graph(example_dirs, task)
61
63
  with new_task("Dump graph"):
62
64
  graph_yaml = atlas_graph.dump_yaml()
63
65
  ensure_parents_write_text(settings.atlas_graph_path, graph_yaml)
64
66
  logger.info(f"Atlas graph dumped to {settings.atlas_graph_path}")
65
67
 
66
68
 
69
+ def create_atlas_graph(example_dirs: list[Path], task: new_task) -> AtlasGraph:
70
+ atlas_graph = AtlasGraph()
71
+
72
+ def on_graph(example_dir: Path, graph: pydot.Dot):
73
+ atlas_graph.add_edges(graph.get_edges())
74
+ atlas_graph.add_variable_edges(example_dir)
75
+
76
+ parse_graphs(on_graph, example_dirs, task)
77
+
78
+ return atlas_graph
79
+
80
+
67
81
  def print_edges(graph: pydot.Dot):
68
82
  edges = graph.get_edges()
69
83
  for edge in edges:
@@ -79,9 +93,15 @@ class ResourceParts(NamedTuple):
79
93
  return self.resource_type.split("_")[0]
80
94
 
81
95
 
96
+ @total_ordering
82
97
  class ResourceRef(BaseModel):
83
98
  full_ref: str
84
99
 
100
+ @model_validator(mode="after")
101
+ def ensure_plain(self):
102
+ self.full_ref = plain_name(self.full_ref)
103
+ return self
104
+
85
105
  def _resource_parts(self) -> ResourceParts:
86
106
  match self.full_ref.split("."):
87
107
  case [resource_type, resource_name] if "_" in resource_type:
@@ -106,6 +126,11 @@ class ResourceRef(BaseModel):
106
126
  def is_module(self) -> bool:
107
127
  return self.full_ref.startswith(MODULE_PREFIX)
108
128
 
129
+ @property
130
+ def module_name(self) -> str:
131
+ assert self.is_module, f"ResourceRef {self.full_ref} is not a module"
132
+ return self.full_ref.removeprefix(MODULE_PREFIX).split(".")[0]
133
+
109
134
  @property
110
135
  def is_data(self) -> bool:
111
136
  return self.full_ref.startswith(DATA_PREFIX)
@@ -114,6 +139,22 @@ class ResourceRef(BaseModel):
114
139
  def resource_type(self) -> str:
115
140
  return self._resource_parts().resource_type
116
141
 
142
+ def __lt__(self, other: object) -> bool:
143
+ if not isinstance(other, ResourceRef):
144
+ raise TypeError(f"cannot compare {type(self)} with {type(other)}")
145
+ return self.full_ref < other.full_ref
146
+
147
+ def __hash__(self) -> int:
148
+ return hash(self.full_ref)
149
+
150
+ def __eq__(self, other: object) -> bool:
151
+ if not isinstance(other, ResourceRef):
152
+ return NotImplemented
153
+ return self.full_ref == other.full_ref
154
+
155
+ def __str__(self) -> str:
156
+ return self.full_ref
157
+
117
158
 
118
159
  class EdgeParsed(BaseModel):
119
160
  parent: ResourceRef
@@ -149,7 +190,15 @@ class EdgeParsed(BaseModel):
149
190
 
150
191
 
151
192
  def edge_plain(edge_endpoint: pydot.EdgeEndpoint) -> str:
152
- return str(edge_endpoint).strip('"').strip()
193
+ return plain_name(str(edge_endpoint))
194
+
195
+
196
+ def node_plain(node: pydot.Node) -> str:
197
+ return plain_name(node.get_name())
198
+
199
+
200
+ def plain_name(name: str) -> str:
201
+ return name.strip('"').strip()
153
202
 
154
203
 
155
204
  def edge_src_dest(edge: pydot.Edge) -> tuple[str, str]:
@@ -235,34 +284,27 @@ class AtlasGraph(Entity):
235
284
  self.parent_child_edges[parent_type].add(child_type)
236
285
 
237
286
 
238
- def parse_graphs(example_dirs: list[Path], task: new_task, max_workers: int = 16, max_dirs: int = 9999) -> AtlasGraph:
239
- atlas_graph = AtlasGraph()
287
+ def parse_graphs(
288
+ on_graph: Callable[[Path, pydot.Dot], None], example_dirs: list[Path], task: new_task, max_dirs: int = 1_000
289
+ ) -> None:
240
290
  with run_pool("parse example graphs", total=len(example_dirs)) as executor:
241
291
  futures = {
242
292
  executor.submit(parse_graph, example_dir): example_dir
243
293
  for i, example_dir in enumerate(example_dirs)
244
294
  if i < max_dirs
245
295
  }
246
- graphs = {}
247
- for future in futures:
296
+ for future, example_dir in futures.items():
248
297
  try:
249
- example_dir, graph_output = future.result()
298
+ _, graph = future.result()
250
299
  except ShellError as e:
251
- logger.error(f"Error parsing graph for {futures[future]}: {e}")
300
+ logger.error(f"Error parsing graph for {example_dir}: {e}")
252
301
  continue
253
302
  except KeyboardInterrupt:
254
303
  logger.error("KeyboardInterrupt received, stopping graph parsing.")
255
304
  stop_runs_and_pool("KeyboardInterrupt", immediate=True)
256
305
  break
257
- try:
258
- graph = graphs[example_dir] = parse_graph_output(example_dir, graph_output)
259
- except GraphParseError as e:
260
- logger.error(e)
261
- continue
262
- atlas_graph.add_edges(graph.get_edges())
263
- atlas_graph.add_variable_edges(example_dir)
306
+ on_graph(example_dir, graph)
264
307
  task.update(advance=1)
265
- return atlas_graph
266
308
 
267
309
 
268
310
  class GraphParseError(Exception):
@@ -271,9 +313,13 @@ class GraphParseError(Exception):
271
313
  super().__init__(f"Failed to parse graph for {example_dir}: {message}")
272
314
 
273
315
 
316
+ _lock = RLock()
317
+
318
+
274
319
  def parse_graph_output(example_dir: Path, graph_output: str, verbose: bool = False) -> pydot.Dot:
275
320
  assert graph_output, f"Graph output is empty for {example_dir}"
276
- dots = pydot.graph_from_dot_data(graph_output) # not thread safe, so we use the main thread here instead
321
+ with _lock:
322
+ dots = pydot.graph_from_dot_data(graph_output)
277
323
  if not dots:
278
324
  raise GraphParseError(example_dir, f"No graphs found in the output:\n{graph_output}")
279
325
  assert len(dots) == 1, f"Expected one graph for {example_dir}, got {len(dots)}"
@@ -297,10 +343,10 @@ class EmptyGraphOutputError(Exception):
297
343
  @retry(
298
344
  stop=stop_after_attempt(3),
299
345
  wait=wait_fixed(1),
300
- retry=retry_if_exception_type(EmptyGraphOutputError),
346
+ retry=retry_if_exception_type((EmptyGraphOutputError, GraphParseError)),
301
347
  reraise=True,
302
348
  )
303
- def parse_graph(example_dir: Path) -> tuple[Path, str]:
349
+ def parse_graph(example_dir: Path) -> tuple[Path, pydot.Dot]:
304
350
  env_vars = {
305
351
  "MONGODB_ATLAS_PREVIEW_PROVIDER_V2_ADVANCED_CLUSTER": "true" if is_v2_example_dir(example_dir) else "false",
306
352
  }
@@ -309,7 +355,8 @@ def parse_graph(example_dir: Path) -> tuple[Path, str]:
309
355
  run_and_wait("terraform init", cwd=example_dir, env=env_vars)
310
356
  run = run_and_wait("terraform graph", cwd=example_dir, env=env_vars)
311
357
  if graph_output := run.stdout_one_line:
312
- return example_dir, graph_output
358
+ graph = parse_graph_output(example_dir, graph_output) # just to make sure we get no errors
359
+ return example_dir, graph
313
360
  raise EmptyGraphOutputError(example_dir)
314
361
 
315
362
 
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+ from collections import defaultdict
3
+ from contextlib import suppress
4
+ from functools import total_ordering
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Callable, ClassVar, Iterable, Protocol, TypeAlias
8
+ from ask_shell import new_task
9
+ from ask_shell.rich_live import get_live_console
10
+ from model_lib import Entity, parse_dict
11
+ from pydantic import Field, model_validator
12
+ import pydot
13
+ from rich.tree import Tree
14
+ import typer
15
+
16
+ from atlas_init.settings.rich_utils import tree_text
17
+ from atlas_init.tf_ext.gen_readme import ReadmeMarkers, generate_and_write_readme
18
+ from atlas_init.tf_ext.models import EmojiCounter
19
+ from atlas_init.tf_ext.models_module import README_FILENAME
20
+ from atlas_init.tf_ext.tf_dep import EdgeParsed, ResourceRef, node_plain, parse_graph, parse_graphs
21
+
22
+ logger = logging.getLogger(__name__)
23
+ MODULES_JSON_RELATIVE_PATH = ".terraform/modules/modules.json"
24
+
25
+
26
+ def tf_example_readme(
27
+ example_path: Path = typer.Option(
28
+ ..., "-e", "--example-path", help="Path to the example directory", default_factory=Path.cwd
29
+ ),
30
+ skip_module_details: list[str] = typer.Option(
31
+ ..., "-s", "--skip-module-details", help="List of module details to skip", default_factory=list
32
+ ),
33
+ ):
34
+ with new_task("parse example graph"):
35
+ _, example_graph_dot = parse_graph(example_path) # ensures init is called
36
+ example_graph = ResourceGraph.from_graph(example_graph_dot)
37
+ with new_task("parse module graphs") as task:
38
+ modules_config = parse_modules_json(example_path, skip_module_details)
39
+ module_paths = modules_config.module_paths
40
+ module_graphs: dict[Path, ResourceGraph] = {}
41
+
42
+ def on_graph(example_dir: Path, graph: pydot.Dot):
43
+ module_graphs[example_dir] = ResourceGraph.from_graph(graph)
44
+
45
+ parse_graphs(on_graph, module_paths, task)
46
+ with new_task("create example module graph"):
47
+ # a graph when all resources in a module are treated as a single node.
48
+ modules_graph, emoji_counter = create_module_graph(example_graph)
49
+ with new_task(f"update {README_FILENAME}"):
50
+ modules_section = []
51
+ modules_trees_texts = []
52
+ module_dirs_used: set[Path] = set()
53
+
54
+ def add_module_tree(module_dir: Path):
55
+ # trees are only once per module, not per module instance
56
+ if module_dir in module_dirs_used:
57
+ return
58
+ module_dirs_used.add(module_dir)
59
+ module_graph = module_graphs[module_dir]
60
+ module_config = modules_config.get_by_path(module_dir)
61
+ emojis = ", ".join(emoji_counter.get_emoji(key) for key in module_config.keys)
62
+ if modules_config.skip_details(module_config):
63
+ tree = Tree(f"{module_dir.name} ({emojis})")
64
+ tree.add("details skipped")
65
+ else:
66
+ tree = module_graph.to_tree(f"{module_dir.name} ({emojis})", include_orphans=True)
67
+ get_live_console().print(tree)
68
+ modules_trees_texts.append(tree_text(tree))
69
+
70
+ for _, module_key in emoji_counter.emoji_name():
71
+ module_config = modules_config.get_by_key(module_key)
72
+ module_dir = module_config.absolute_path(example_path)
73
+ add_module_tree(module_dir)
74
+
75
+ def add_module_src(node: Tree, name: str) -> None:
76
+ config = modules_config.get_by_key(name)
77
+ node.add(f"{config.source}")
78
+
79
+ module_index_tree = emoji_tree(emoji_counter, tree_processor=add_module_src, name="Module Instances")
80
+ modules_section.extend(
81
+ [
82
+ "## Modules",
83
+ "",
84
+ "### Modules Instances",
85
+ "```sh",
86
+ tree_text(module_index_tree),
87
+ "```",
88
+ "### Module Definitions",
89
+ "",
90
+ "```sh",
91
+ "\n".join(modules_trees_texts),
92
+ "```",
93
+ "",
94
+ "### Graph with Dependencies",
95
+ "Any resource without a number prefix is defined at the root level.",
96
+ "",
97
+ as_mermaid(modules_graph),
98
+ ]
99
+ )
100
+ generators = ReadmeMarkers.readme_generators()
101
+ generators.insert(1, (ReadmeMarkers.MODULES, lambda _: "\n".join(modules_section)))
102
+ generate_and_write_readme(
103
+ example_path,
104
+ generators=generators,
105
+ )
106
+
107
+
108
+ def create_module_graph(example_graph: ResourceGraph) -> tuple[ResourceGraph, EmojiCounter]:
109
+ emoji_counter = EmojiCounter()
110
+
111
+ def as_module_edge(parent: ResourceRef, child: ResourceRef) -> bool | ParentChild:
112
+ if not child.is_module:
113
+ return False
114
+ new_parent = add_emoji_prefix(parent, emoji_counter)
115
+ new_child = add_emoji_prefix(child, emoji_counter)
116
+ return new_parent, new_child
117
+
118
+ return create_subgraph(example_graph, as_module_edge), emoji_counter
119
+
120
+
121
+ def emoji_tree(
122
+ counter: EmojiCounter, *, tree_processor: Callable[[Tree, str], None] | None = None, name: str = "Emoji Tree"
123
+ ) -> Tree:
124
+ tree = Tree(name)
125
+ for emoji, name in counter.emoji_name():
126
+ child = tree.add(f"{emoji} {name}")
127
+ if tree_processor:
128
+ tree_processor(child, name)
129
+ return tree
130
+
131
+
132
+ def add_emoji_prefix(ref: ResourceRef, emoji_counter: EmojiCounter) -> ResourceRef:
133
+ if ref.is_module:
134
+ return ResourceRef(full_ref=f"{emoji_counter.get_emoji(ref.module_name)} {ref.module_name}")
135
+ return ref
136
+
137
+
138
+ def strip_emoji_prefix(ref: ResourceRef) -> ResourceRef:
139
+ old_ref = ref.full_ref
140
+ return ResourceRef(full_ref=old_ref.split(" ")[1]) if " " in old_ref else ref
141
+
142
+
143
+ def as_module_name(ref: ResourceRef) -> str:
144
+ if ref.is_module:
145
+ return ref.module_name
146
+ return ""
147
+
148
+
149
+ def as_module_ref(ref: ResourceRef) -> ResourceRef:
150
+ if name := as_module_name(ref):
151
+ return ResourceRef(full_ref=f"module.{name}")
152
+ return ref
153
+
154
+
155
+ class _RootModuleIgnored(Exception):
156
+ pass
157
+
158
+
159
+ ParentChild: TypeAlias = tuple[ResourceRef, ResourceRef]
160
+
161
+
162
+ class ResourceGraph(Entity):
163
+ IGNORED_ORPHANS: ClassVar[set[str]] = {"node"} # some extra output from `terraform graph` command
164
+ parent_children: dict[ResourceRef, set[ResourceRef]] = Field(default_factory=lambda: defaultdict(set))
165
+ children_parents: dict[ResourceRef, set[ResourceRef]] = Field(default_factory=lambda: defaultdict(set))
166
+ orphans: set[ResourceRef] = Field(default_factory=set)
167
+
168
+ @classmethod
169
+ def from_graph(cls, graph: pydot.Dot) -> "ResourceGraph":
170
+ resource_graph = cls()
171
+ resource_graph.add_edges(graph.get_edges())
172
+ for orphan in graph.get_node_list():
173
+ name = node_plain(orphan)
174
+ if name in cls.IGNORED_ORPHANS:
175
+ continue
176
+ ref = ResourceRef(full_ref=name)
177
+ resource_graph.add_orphan_if_not_found(ref)
178
+ return resource_graph
179
+
180
+ def add_orphan_if_not_found(self, orphan: ResourceRef):
181
+ if orphan not in self.parent_children and orphan not in self.children_parents:
182
+ self.orphans.add(orphan)
183
+
184
+ def add_edges(self, edges: list[pydot.Edge]):
185
+ for edge in edges:
186
+ parsed = EdgeParsed.from_edge(edge)
187
+ parent = parsed.parent
188
+ child = parsed.child
189
+ if str(parent) == ".this":
190
+ logger.info(f"parent: {parent} child: {child}")
191
+ self.add_edge(parent, child)
192
+
193
+ def add_edge(self, parent: ResourceRef, child: ResourceRef):
194
+ self.parent_children[parent].add(child)
195
+ self.children_parents[child].add(parent)
196
+
197
+ def all_edges(self) -> list[ParentChild]:
198
+ return [(parent, child) for parent in self.parent_children for child in self.parent_children[parent]]
199
+
200
+ @property
201
+ def all_parents(self) -> set[ResourceRef]:
202
+ return set(self.parent_children.keys())
203
+
204
+ def sorted_parents(self) -> Iterable[ResourceRef]:
205
+ used_parents = set()
206
+ remaining_parents = self.all_parents
207
+
208
+ def next_parent() -> ResourceRef | None:
209
+ candidates = [parent for parent in remaining_parents if not self.children_parents[parent] - used_parents]
210
+ return min(candidates) if candidates else None
211
+
212
+ while remaining_parents:
213
+ parent = next_parent()
214
+ if parent is None:
215
+ break
216
+ used_parents.add(parent)
217
+ yield parent
218
+ remaining_parents.remove(parent)
219
+
220
+ def to_tree(self, example_dir_name: str, include_orphans: bool = False) -> Tree:
221
+ root = Tree(example_dir_name)
222
+ trees: dict[ResourceRef, Tree] = {}
223
+
224
+ for parent in self.sorted_parents():
225
+ parent_tree = trees.setdefault(parent, Tree(parent.full_ref))
226
+ for child_ref in sorted(self.parent_children[parent]):
227
+ child_tree = trees.setdefault(child_ref, Tree(child_ref.full_ref))
228
+ parent_tree.add(child_tree)
229
+ if not self.children_parents[parent]:
230
+ root.add(parent_tree)
231
+ if include_orphans:
232
+ for orphan in sorted(self.orphans):
233
+ if orphan not in trees:
234
+ root.add(Tree(orphan.full_ref))
235
+ return root
236
+
237
+
238
+ def as_mermaid(graph: ResourceGraph) -> str:
239
+ nodes: dict[str, str] = {}
240
+
241
+ def mermaid_ref(ref: ResourceRef) -> str:
242
+ no_emoji = strip_emoji_prefix(ref)
243
+ mermaid = str(no_emoji)
244
+ assert '"' not in mermaid, f"mermaid ref should not contain quotes: {mermaid}"
245
+ nodes[mermaid] = str(ref) # want original ref in label
246
+ return mermaid
247
+
248
+ def add_mermaid_edge(parent: ResourceRef, child: ResourceRef) -> str:
249
+ parent_ref = mermaid_ref(parent)
250
+ child_ref = mermaid_ref(child)
251
+ return f"{parent_ref} --> {child_ref}"
252
+
253
+ edges = [add_mermaid_edge(parent, child) for parent, child in graph.all_edges()]
254
+ return "\n".join(
255
+ [
256
+ "```mermaid",
257
+ "graph TD",
258
+ " " + "\n ".join(f'{mermaid}["{label}"]' for mermaid, label in sorted(nodes.items())),
259
+ " " + "\n ".join(sorted(edges)),
260
+ "```",
261
+ ]
262
+ )
263
+
264
+
265
+ class EdgeFilter(Protocol):
266
+ def __call__(self, parent: ResourceRef, child: ResourceRef) -> bool | ParentChild: ...
267
+
268
+
269
+ def create_subgraph(graph: ResourceGraph, edge_filter: EdgeFilter) -> ResourceGraph:
270
+ subgraph = ResourceGraph()
271
+ for parent in graph.sorted_parents():
272
+ for child in sorted(graph.parent_children[parent]):
273
+ filter_response = edge_filter(parent, child)
274
+ match filter_response:
275
+ case True:
276
+ subgraph.add_edge(parent, child)
277
+ case False:
278
+ continue
279
+ case (parent, child) if parent != child:
280
+ subgraph.add_edge(parent, child)
281
+ return subgraph
282
+
283
+
284
+ @total_ordering
285
+ class ModuleExampleConfig(Entity):
286
+ keys: list[str]
287
+ rel_path: str = Field(alias="Dir", description="Relative path to the module example")
288
+ source: str = Field(
289
+ alias="Source",
290
+ description="Source of the module, for example: registry.terraform.io/terraform-aws-modules/vpc/aws",
291
+ )
292
+ version: str = Field(
293
+ alias="Version", description="Version of the module example, unset for local modules", default=""
294
+ )
295
+
296
+ @model_validator(mode="before")
297
+ @classmethod
298
+ def move_key(cls, v: dict):
299
+ key = v.pop("Key", None)
300
+ if key:
301
+ v["keys"] = [key]
302
+ if v.get("Dir", "") == ".":
303
+ raise _RootModuleIgnored()
304
+ return v
305
+
306
+ @model_validator(mode="after")
307
+ def validate_keys(self):
308
+ if not self.keys:
309
+ raise ValueError("keys is required")
310
+ return self
311
+
312
+ @property
313
+ def key(self) -> str:
314
+ return ",".join(sorted(self.keys))
315
+
316
+ def absolute_path(self, example_path: Path) -> Path:
317
+ path = example_path / self.rel_path
318
+ if not path.exists():
319
+ raise ValueError(f"module path not found for {self.key}: {path}")
320
+ return path
321
+
322
+ def __lt__(self, other: object) -> bool:
323
+ if not isinstance(other, ModuleExampleConfig):
324
+ raise TypeError(f"cannot compare {type(self)} with {type(other)}")
325
+ return self.key < other.key
326
+
327
+
328
+ class ModuleExampleConfigs(Entity):
329
+ example_path: Path
330
+ modules: dict[str, ModuleExampleConfig] = Field(default_factory=dict)
331
+ skip_module_details: set[str] = Field(default_factory=set)
332
+
333
+ @model_validator(mode="after")
334
+ def ensure_paths_exists(self):
335
+ not_exists: dict[str, Path] = {}
336
+ for config in self.modules.values():
337
+ path = config.absolute_path(self.example_path)
338
+ if not path.exists():
339
+ not_exists[config.key] = path
340
+ if not_exists:
341
+ raise ValueError(f"module paths not found: {not_exists}")
342
+ return self
343
+
344
+ @property
345
+ def module_paths(self) -> list[Path]:
346
+ return [config.absolute_path(self.example_path) for config in self.modules.values()]
347
+
348
+ def skip_details(self, config: ModuleExampleConfig) -> bool:
349
+ return any(key in self.skip_module_details for key in config.keys)
350
+
351
+ def get_by_path(self, module_dir: Path) -> ModuleExampleConfig:
352
+ for config in self.modules.values():
353
+ if config.absolute_path(self.example_path) == module_dir:
354
+ return config
355
+ raise ValueError(f"module not found for {module_dir}")
356
+
357
+ def get_by_key_or_none(self, key: str) -> ModuleExampleConfig | None:
358
+ return self.modules.get(key)
359
+
360
+ def get_by_key(self, key: str) -> ModuleExampleConfig:
361
+ return self.modules[key]
362
+
363
+ def modules_included(self, *, skip_keys: list[str]) -> list[ModuleExampleConfig]:
364
+ return [config for config in self.modules.values() if all(key not in skip_keys for key in config.keys)]
365
+
366
+ def add_module(self, config: ModuleExampleConfig):
367
+ key = config.keys[0]
368
+ assert len(config.keys) == 1, "only one key can be added at a time"
369
+ source = config.source
370
+ existing_config = next(
371
+ (existing_config for existing_config in self.modules.values() if source == existing_config.source),
372
+ None,
373
+ )
374
+ if existing_config:
375
+ existing_config.keys.append(key)
376
+ existing_config.keys.sort()
377
+ self.modules[key] = existing_config
378
+ else:
379
+ self.modules[key] = config
380
+
381
+
382
+ def parse_modules_json(example_path: Path, skip_module_details: list[str] | None = None) -> ModuleExampleConfigs:
383
+ configs = ModuleExampleConfigs(example_path=example_path, skip_module_details=set(skip_module_details or []))
384
+ module_json_path = example_path / MODULES_JSON_RELATIVE_PATH
385
+ if not module_json_path.exists():
386
+ return configs
387
+ module_json = parse_dict(module_json_path)
388
+ for raw in module_json.get("Modules", []):
389
+ with suppress(_RootModuleIgnored):
390
+ config = ModuleExampleConfig(**raw)
391
+ configs.add_module(config)
392
+ return configs
@@ -12,7 +12,7 @@ from zero_3rdparty.file_utils import clean_dir, copy, ensure_parents_write_text
12
12
  from atlas_init.cli_tf.example_update import UpdateExamples, update_examples
13
13
  from atlas_init.tf_ext.args import TF_CLI_CONFIG_FILE_ARG
14
14
  from atlas_init.tf_ext.gen_examples import generate_module_examples, read_example_dirs
15
- from atlas_init.tf_ext.gen_module_readme import generate_readme
15
+ from atlas_init.tf_ext.gen_readme import generate_and_write_readme
16
16
  from atlas_init.tf_ext.gen_resource_main import generate_resource_main
17
17
  from atlas_init.tf_ext.gen_resource_output import generate_resource_output
18
18
  from atlas_init.tf_ext.gen_resource_variables import generate_module_variables
@@ -32,6 +32,7 @@ from atlas_init.tf_ext.plan_diffs import (
32
32
  read_variables_path,
33
33
  )
34
34
  from atlas_init.tf_ext.provider_schema import AtlasSchemaInfo, ResourceSchema, parse_atlas_schema
35
+ from atlas_init.tf_ext.run_tf import validate_tf_workspace
35
36
  from atlas_init.tf_ext.schema_to_dataclass import convert_and_format
36
37
  from atlas_init.tf_ext.settings import TfExtSettings
37
38
 
@@ -131,29 +132,13 @@ def generate_resource_module(config: ModuleGenConfig, resource_type: str, atlas_
131
132
  def finalize_and_validate_module(config: ModuleGenConfig) -> Path:
132
133
  dump_versions_tf(config.module_out_path, skip_python=config.skip_python)
133
134
  logger.info(f"Module dumped to {config.module_out_path}, running checks")
134
- validate_module(config.module_out_path, tf_cli_config_file=config.settings.tf_cli_config_file)
135
+ validate_tf_workspace(config.module_out_path, tf_cli_config_file=config.settings.tf_cli_config_file)
135
136
  return config.module_out_path
136
137
 
137
138
 
138
139
  OUT_BINARY_PATH = "tfplan.binary"
139
140
 
140
141
 
141
- def validate_module(tf_workdir: Path, *, tf_cli_config_file: Path | None = None):
142
- terraform_commands = [
143
- "terraform init",
144
- "terraform fmt .",
145
- "terraform validate .",
146
- ]
147
- env_extra = {}
148
- if tf_cli_config_file:
149
- env_extra["TF_CLI_CONFIG_FILE"] = str(tf_cli_config_file)
150
- with new_task("Terraform Module Validate Checks", total=len(terraform_commands)) as task:
151
- for command in terraform_commands:
152
- attempts = 3 if command == "terraform init" else 1 # terraform init can fail due to network issues
153
- run_and_wait(command, cwd=tf_workdir, env=env_extra, attempts=attempts)
154
- task.update(advance=1)
155
-
156
-
157
142
  def module_examples_and_readme(config: ModuleGenConfig, *, example_var_file: Path | None = None) -> Path:
158
143
  path = config.module_out_path
159
144
  if (examples_test := config.examples_test_path) and examples_test.exists():
@@ -165,7 +150,7 @@ def module_examples_and_readme(config: ModuleGenConfig, *, example_var_file: Pat
165
150
  if examples_generated:
166
151
  with run_pool("Validating examples", total=len(examples_generated), exit_wait_timeout=60) as pool:
167
152
  for example_path in examples_generated:
168
- pool.submit(validate_module, example_path)
153
+ pool.submit(validate_tf_workspace, example_path)
169
154
 
170
155
  attribute_descriptions = parse_attribute_descriptions(config.settings)
171
156
  settings = config.settings
@@ -192,12 +177,10 @@ def module_examples_and_readme(config: ModuleGenConfig, *, example_var_file: Pat
192
177
  if out_event.changes:
193
178
  logger.info(f"Updated attribute descriptions: {len(out_event.changes)}")
194
179
  run_and_wait("terraform fmt -recursive .", cwd=path, ansi_content=False, allow_non_zero_exit=True)
195
- if readme_path := config.readme_path():
196
- with new_task("Generating README.md"):
197
- readme_content = generate_readme(config)
198
- ensure_parents_write_text(readme_path, readme_content)
180
+ with new_task("Generating README.md"):
181
+ generate_and_write_readme(config.module_out_path)
199
182
  if example_var_file:
200
- examples = read_example_dirs(config.module_out_path)
183
+ examples = read_example_dirs(config.examples_path)
201
184
  if examples:
202
185
  failed_examples: list[Path] = []
203
186
  with run_pool("Running terraform plan on examples", total=len(examples), exit_wait_timeout=60) as pool:
@@ -227,7 +210,7 @@ def example_plan_checks(config: ModuleGenConfig, timeout_all_seconds: int = 60)
227
210
  with TemporaryDirectory() as temp_dir:
228
211
  stored_plan = Path(temp_dir) / "plan.json"
229
212
  tf_dir = config.example_path(check.example_name)
230
- validate_module(tf_dir)
213
+ validate_tf_workspace(tf_dir)
231
214
  var_arg = f" -var-file={variables_path}" if variables_path else ""
232
215
  run_and_wait(f"terraform plan -out={OUT_BINARY_PATH}{var_arg}", cwd=tf_dir)
233
216
  run_and_wait(f"terraform show -json {OUT_BINARY_PATH} > {stored_plan}", cwd=tf_dir)
@@ -175,7 +175,9 @@ def tf_modules(
175
175
  logger.info(f"Missing modules: \n{'\n'.join(missing_resources)}")
176
176
 
177
177
 
178
- def generate_module_graphs(skipped_module_resource_types, settings, atlas_graph):
178
+ def generate_module_graphs(
179
+ skipped_module_resource_types: Iterable[str], settings: TfExtSettings, atlas_graph: AtlasGraph
180
+ ):
179
181
  tree = Tree(
180
182
  "Module graphs",
181
183
  )