feat(generate): support named output paths

This commit is contained in:
ChristianLempa
2026-05-03 19:30:35 +02:00
parent 355bc9b268
commit 9b47551b7b
3 changed files with 112 additions and 2 deletions
+49
View File
@@ -53,6 +53,7 @@ class GenerationConfig:
dry_run: bool = False
show_files: bool = False
quiet: bool = False
name: str | None = None
def list_templates(module_instance, raw: bool = False) -> list:
@@ -322,6 +323,53 @@ def execute_remote_dry_run(
return total_files, size_str
def _validate_output_name(name: str) -> str:
"""Validate and normalize a generated output name."""
normalized_name = name.strip()
if not normalized_name:
raise ValueError("--name cannot be empty")
if "/" in normalized_name or "\\" in normalized_name:
raise ValueError("--name must be a file name, not a path")
if normalized_name in {".", ".."}:
raise ValueError("--name cannot be '.' or '..'")
return normalized_name
def _prefix_path_part(name: str, part: str) -> str:
"""Prefix one top-level path part with the generated output name."""
path = Path(part)
if path.stem == "main" and path.suffix:
return f"{name}{path.suffix}"
return f"{name}_{part}"
def apply_output_name(rendered_files: dict[str, str], name: str | None) -> dict[str, str]:
"""Rename top-level generated paths with a user-provided output name.
The top-level entrypoint file named ``main.<ext>`` becomes ``<name>.<ext>``.
All other top-level files or directories receive ``<name>_`` as a prefix.
Nested path segments are preserved unchanged.
"""
if name is None:
return rendered_files
normalized_name = _validate_output_name(name)
renamed_files: dict[str, str] = {}
for file_path, content in rendered_files.items():
path = Path(file_path)
parts = path.parts
if not parts:
continue
renamed_top_level = _prefix_path_part(normalized_name, parts[0])
renamed_path = Path(renamed_top_level, *parts[1:]).as_posix()
if renamed_path in renamed_files:
raise ValueError(f"--name creates duplicate generated path: {renamed_path}")
renamed_files[renamed_path] = content
return renamed_files
def write_rendered_files(output_dir: Path, rendered_files: dict[str, str]) -> None:
"""Write rendered files to the output directory."""
output_dir.mkdir(parents=True, exist_ok=True)
@@ -438,6 +486,7 @@ def generate_template(module_instance, config: GenerationConfig) -> None: # noq
try:
rendered_files, _variable_values = _render_template(template, config.id, display, config.interactive)
rendered_files = apply_output_name(rendered_files, config.name)
if destination is None:
if config.dry_run:
+9 -1
View File
@@ -186,10 +186,17 @@ class Module(ABC):
bool,
Option(
"--interactive/--no-interactive",
"-i/-n",
help="Enable interactive prompting for variables",
),
] = True,
name: Annotated[
str | None,
Option(
"--name",
"-n",
help="Rename top-level generated files/directories with this name",
),
] = None,
var: Annotated[
list[str] | None,
Option(
@@ -235,6 +242,7 @@ class Module(ABC):
interactive=interactive,
var=var,
var_file=var_file,
name=name,
dry_run=dry_run,
show_files=show_files,
quiet=quiet,
+54 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from types import SimpleNamespace
from cli.core.module.base_commands import GenerationConfig, generate_template, list_templates
from cli.core.module.base_commands import GenerationConfig, apply_output_name, generate_template, list_templates
def _noop(*_args, **_kwargs) -> None:
@@ -78,6 +78,59 @@ def test_list_templates_raw_outputs_tab_separated_rows() -> None:
assert display.lines == ["whoami\tWhoami\tdocker,test\t1.0.0\tdefault"]
def test_apply_output_name_renames_top_level_paths_only() -> None:
"""Named generation should rename top-level outputs while preserving nested names."""
rendered_files = {
"files/test.txt": "nested",
"main.tf": "main",
"dns.tf": "dns",
}
assert apply_output_name(rendered_files, "servertest1") == {
"servertest1_files/test.txt": "nested",
"servertest1.tf": "main",
"servertest1_dns.tf": "dns",
}
def test_generate_template_applies_output_name_before_writing(monkeypatch, tmp_path) -> None:
"""Generate should write renamed paths when --name is provided."""
display = _DisplayCapture()
template = SimpleNamespace(id="terraform", slug="terraform")
module_instance = SimpleNamespace(name="terraform", display=display)
written: dict[str, object] = {}
monkeypatch.setattr("cli.core.module.base_commands._prepare_template", lambda *_args, **_kwargs: template)
monkeypatch.setattr(
"cli.core.module.base_commands._render_template",
lambda *_args, **_kwargs: ({"files/test.txt": "nested", "main.tf": "main", "dns.tf": "dns"}, {}),
)
monkeypatch.setattr("cli.core.module.base_commands.check_output_directory", lambda *_args, **_kwargs: [])
def capture_write(output_dir, rendered_files):
written["output_dir"] = output_dir
written["rendered_files"] = rendered_files
monkeypatch.setattr("cli.core.module.base_commands.write_rendered_files", capture_write)
generate_template(
module_instance,
GenerationConfig(
id="terraform",
output=str(tmp_path),
interactive=False,
name="servertest1",
),
)
assert written["output_dir"] == tmp_path
assert written["rendered_files"] == {
"servertest1_files/test.txt": "nested",
"servertest1.tf": "main",
"servertest1_dns.tf": "dns",
}
def test_generate_template_dry_run_skips_destination_prompt_and_overwrite_check(
monkeypatch,
) -> None: