nac-cli 0.1.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.
nac/cli/passthrough.py ADDED
@@ -0,0 +1,117 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """Shared plumbing for forwarding extra CLI args to a wrapped tool and for
5
+ delegating `--help`/`-h` to that tool's own help output.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import NoReturn
15
+
16
+ import typer
17
+
18
+ from nac.output import BOLD, color, format_command, utf8_env
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # `options_metavar` replaces the bare `[OPTIONS]` in nac's own usage line so it
23
+ # is clear that unrecognised arguments are forwarded to the wrapped tool.
24
+ PASSTHROUGH_OPTIONS_METAVAR = "[OPTIONS] [PASSTHROUGH-ARGS]..."
25
+
26
+ _ANSI = r"(?:\x1b\[[0-9;]*m|\s)*"
27
+ # First Rich panel border (`╭─ Arguments ─╮` / `╭─ Options ─╮`), possibly
28
+ # preceded by ANSI styling.
29
+ _FIRST_PANEL_RE = re.compile(r"^(?:\x1b\[[0-9;]*m)*╭", re.MULTILINE)
30
+ # First options section heading in terraform/tofu help (`Options:`,
31
+ # `Plan Customization Options:`, ...).
32
+ _FIRST_OPTIONS_HEADING_RE = re.compile(r"^[A-Za-z ]*Options:$", re.MULTILINE)
33
+ _USAGE_PROG_RE = re.compile(
34
+ rf"(Usage:{_ANSI})(?:nac-validate|nac-test|terraform|tofu)(?: \[global options\](?: [a-z]+)?)?"
35
+ )
36
+
37
+ PASSTHROUGH_CONTEXT_SETTINGS = {
38
+ "allow_extra_args": True,
39
+ "ignore_unknown_options": True,
40
+ "help_option_names": [],
41
+ }
42
+
43
+
44
+ def wants_help(ctx: typer.Context) -> bool:
45
+ """Whether the leftover passthrough args include a help flag.
46
+
47
+ Safe to check anywhere in `ctx.args` (not just the first token) since
48
+ `ignore_unknown_options` guarantees nothing else has been pre-interpreted,
49
+ and none of the wrapped tools (nac-validate, nac-test, terraform/tofu)
50
+ have an unrelated `-h` short flag that could collide.
51
+ """
52
+ return "--help" in ctx.args or "-h" in ctx.args
53
+
54
+
55
+ def _tool_help_body(text: str, usage_as: str) -> str:
56
+ """Reduce the wrapped tool's help to what nac's own help doesn't cover.
57
+
58
+ Rich-based tools (nac-validate, nac-test) print a usage line and a
59
+ description before their Arguments/Options panels, and terraform/tofu
60
+ print theirs before the first `... Options:` heading; both are dropped,
61
+ as nac already printed its own usage and description. If neither marker
62
+ is found, the full text is kept with its usage line rewritten to the
63
+ `nac <command>` form the user actually types.
64
+ """
65
+ start = _FIRST_PANEL_RE.search(text) or _FIRST_OPTIONS_HEADING_RE.search(text)
66
+ if start:
67
+ return text[start.start() :]
68
+ return _USAGE_PROG_RE.sub(lambda m: m.group(1) + usage_as, text, count=1)
69
+
70
+
71
+ def show_help_and_exit(
72
+ ctx: typer.Context,
73
+ tool_argv: list[str],
74
+ *,
75
+ cwd: Path,
76
+ env: dict[str, str],
77
+ no_color: bool,
78
+ tool_label: str,
79
+ usage_as: str,
80
+ ) -> NoReturn:
81
+ """Print nac's own help for this subcommand, then the wrapped tool's
82
+ own `--help` output below a labeled separator, and exit with that
83
+ tool's code.
84
+
85
+ The tool's output is captured (its stdout is not a TTY) so that:
86
+ - color and terminal width are forced on for it when nac's own stdout is
87
+ a TTY (and color is not disabled), matching nac's own help rendering;
88
+ - its own usage/description header is dropped (see `_tool_help_body`),
89
+ since nac already printed its own, leaving just the options.
90
+ """
91
+ typer.echo(ctx.get_help())
92
+
93
+ child_env = utf8_env(env)
94
+ if sys.stdout.isatty():
95
+ child_env["COLUMNS"] = str(shutil.get_terminal_size().columns)
96
+ if not no_color:
97
+ child_env["FORCE_COLOR"] = "1"
98
+
99
+ logger.debug("Running %s", format_command(tool_argv))
100
+ proc = subprocess.run(
101
+ tool_argv,
102
+ cwd=cwd,
103
+ env=child_env,
104
+ stdout=subprocess.PIPE,
105
+ stderr=subprocess.STDOUT,
106
+ text=True,
107
+ encoding="utf-8",
108
+ errors="replace",
109
+ )
110
+ text = _tool_help_body(proc.stdout, usage_as)
111
+
112
+ rule = f"── Passed through to {tool_label} "
113
+ rule += "─" * max(0, shutil.get_terminal_size().columns - len(rule))
114
+ typer.echo(color(BOLD, rule, no_color=no_color))
115
+ typer.echo()
116
+ typer.echo(text, nl=False)
117
+ raise typer.Exit(code=proc.returncode)
nac/cli/plan.py ADDED
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """`nac plan` -- saves a plan file and, optionally, plan.txt/plan.json."""
5
+
6
+ import typer
7
+
8
+ from nac import runner, terraform
9
+
10
+ from .main import app, get_config, get_no_color, resolve_engine_or_exit
11
+ from .options import Artifacts
12
+ from .passthrough import (
13
+ PASSTHROUGH_CONTEXT_SETTINGS,
14
+ PASSTHROUGH_OPTIONS_METAVAR,
15
+ show_help_and_exit,
16
+ wants_help,
17
+ )
18
+
19
+
20
+ @app.command(
21
+ context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
22
+ options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
23
+ )
24
+ def plan(ctx: typer.Context, artifacts: Artifacts = False) -> None:
25
+ """Produce a saved plan file, optionally with plan.txt/plan.json artifacts.
26
+
27
+ Extra arguments are passed through to `terraform`/`tofu` (only to the
28
+ `plan` invocation itself, not to the `show` calls used for artifacts).
29
+ """
30
+ cfg = get_config(ctx)
31
+ engine, binary = resolve_engine_or_exit(cfg)
32
+ env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
33
+ no_color = get_no_color(ctx)
34
+
35
+ if wants_help(ctx):
36
+ show_help_and_exit(
37
+ ctx,
38
+ [binary, "plan", "--help"],
39
+ cwd=cfg.working_dir,
40
+ env=env,
41
+ no_color=get_no_color(ctx),
42
+ tool_label=f"{engine} plan",
43
+ usage_as="nac plan",
44
+ )
45
+
46
+ code = runner.run_streaming(
47
+ terraform.build_plan_argv(binary, no_color=no_color) + ctx.args,
48
+ cwd=cfg.working_dir,
49
+ env=env,
50
+ no_color=no_color,
51
+ )
52
+ if code != 0:
53
+ raise typer.Exit(code=code)
54
+
55
+ if artifacts:
56
+ code = runner.run_streaming(
57
+ terraform.build_show_text_argv(binary, no_color=no_color),
58
+ cwd=cfg.working_dir,
59
+ env=env,
60
+ log_path=cfg.working_dir / "plan.txt",
61
+ no_color=no_color,
62
+ )
63
+ if code != 0:
64
+ raise typer.Exit(code=code)
65
+
66
+ code = runner.run_streaming(
67
+ terraform.build_show_json_argv(binary),
68
+ cwd=cfg.working_dir,
69
+ env=env,
70
+ log_path=cfg.working_dir / "plan.json",
71
+ no_color=no_color,
72
+ )
73
+ if code != 0:
74
+ raise typer.Exit(code=code)
75
+
76
+ raise typer.Exit(code=0)
nac/cli/setup.py ADDED
@@ -0,0 +1,179 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """`nac setup` -- verify prerequisites and report resolved tool versions."""
5
+
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ import typer
15
+
16
+ from nac import bootstrap, runner, terraform, tools
17
+ from nac.exceptions import BootstrapError, EngineNotFoundError, NoEngineAvailableError
18
+ from nac.manifest import MANIFEST_FILE
19
+ from nac.output import apply_no_color_env, echo_summary, format_command
20
+ from nac.tools import ToolSource
21
+
22
+ from .main import app, fail, get_config, get_no_color
23
+ from .options import Prewarm, Yes
24
+ from .versions import emit_warnings, get_manifest, tool_warnings
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ UV_NOT_FOUND = (
29
+ "`uv` not found on PATH -- install it: "
30
+ "https://docs.astral.sh/uv/getting-started/installation/"
31
+ )
32
+ TENV_NOT_FOUND = (
33
+ "`tenv` not found on PATH -- required because `tools.terraform.version` "
34
+ "is set. Install tenv: https://github.com/tofuutils/tenv"
35
+ )
36
+ MANUAL_INSTALL_MESSAGE = (
37
+ "Neither Terraform nor OpenTofu is installed. Install Terraform or "
38
+ "OpenTofu: https://developer.hashicorp.com/terraform/install or "
39
+ "https://opentofu.org/docs/intro/install/ -- or set "
40
+ "`tools.terraform.version` to have `nac` manage it via `tenv`."
41
+ )
42
+
43
+
44
+ def _is_interactive() -> bool:
45
+ return bool(sys.stdin.isatty())
46
+
47
+
48
+ def _confirm_bootstrap(yes: bool) -> bool:
49
+ if yes:
50
+ return True
51
+ if not _is_interactive():
52
+ return False
53
+ return typer.confirm(
54
+ "Neither Terraform nor OpenTofu is installed. Install OpenTofu now?",
55
+ default=True,
56
+ )
57
+
58
+
59
+ def _binary_version(binary: str, env: dict[str, str]) -> str:
60
+ argv = [binary, "version"]
61
+ logger.debug("Running %s", format_command(argv))
62
+ result = subprocess.run(
63
+ argv,
64
+ capture_output=True,
65
+ text=True,
66
+ encoding="utf-8",
67
+ errors="replace",
68
+ env=env,
69
+ check=False,
70
+ )
71
+ for line in (result.stdout or result.stderr or "").splitlines():
72
+ if line.strip():
73
+ return line.strip()
74
+ return "unknown"
75
+
76
+
77
+ def _is_cached_binary(binary: str) -> bool:
78
+ """True if `binary` lives under nac's bootstrap cache dir rather than PATH."""
79
+ try:
80
+ Path(binary).resolve().relative_to(bootstrap.cache_dir().resolve())
81
+ except ValueError:
82
+ return False
83
+ return True
84
+
85
+
86
+ @app.command()
87
+ def setup(ctx: typer.Context, yes: Yes = False, prewarm: Prewarm = False) -> None:
88
+ """Verify prerequisites and report resolved tool versions."""
89
+ cfg = get_config(ctx)
90
+ no_color = get_no_color(ctx)
91
+
92
+ if shutil.which("uv") is None:
93
+ fail(UV_NOT_FOUND)
94
+ lines: list[str] = ["uv: found"]
95
+
96
+ if cfg.tools.terraform.version is not None and shutil.which("tenv") is None:
97
+ fail(TENV_NOT_FOUND)
98
+
99
+ try:
100
+ engine, binary = terraform.resolve_engine_binary(cfg.tools.terraform)
101
+ if cfg.tools.terraform.engine is not None:
102
+ detection = "explicit"
103
+ elif _is_cached_binary(binary):
104
+ detection = "bootstrap-installed"
105
+ else:
106
+ detection = "auto-detected"
107
+ except EngineNotFoundError as exc:
108
+ fail(str(exc))
109
+ except NoEngineAvailableError:
110
+ if not _confirm_bootstrap(yes):
111
+ fail(MANUAL_INSTALL_MESSAGE)
112
+ try:
113
+ binary_path = bootstrap.install(confirmed=True)
114
+ except BootstrapError as exc:
115
+ fail(str(exc))
116
+ engine, binary, detection = "tofu", str(binary_path), "bootstrap-installed"
117
+
118
+ env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
119
+ lines.append(f"{engine}: {_binary_version(binary, env)} ({detection})")
120
+
121
+ manifest = get_manifest(ctx, cfg)
122
+ if manifest is not None:
123
+ lines.append(f"tested versions: {manifest.module} ({MANIFEST_FILE})")
124
+
125
+ missing_env = [name for name in cfg.env.required if name not in os.environ]
126
+ if missing_env:
127
+ fail("Missing required environment variable(s): " + ", ".join(missing_env))
128
+ if cfg.env.required:
129
+ lines.append("env.required: all present (" + ", ".join(cfg.env.required) + ")")
130
+
131
+ def _tool_summary_line(
132
+ name: Literal["nac-validate", "nac-test"], explicit: str | None
133
+ ) -> tuple[str, ToolSource, str | None]:
134
+ constraint = tools.tool_constraint(name, explicit, manifest)
135
+ source = tools.resolve_tool_source(name, constraint)
136
+ if source.mode == "local":
137
+ return f"{name}: v{source.version or 'unknown'} (local)", source, constraint
138
+ pin = constraint or "latest/unpinned"
139
+ if explicit is None and constraint is not None and manifest is not None:
140
+ pin += f", latest compatible with {manifest.module}"
141
+ suffix = (
142
+ f" -- local v{source.version} does not satisfy"
143
+ if source.version is not None
144
+ else ""
145
+ )
146
+ return f"{name}: {pin} (uvx{suffix})", source, constraint
147
+
148
+ validate_line, validate_source, validate_constraint = _tool_summary_line(
149
+ "nac-validate", cfg.tools.nac_validate
150
+ )
151
+ test_line, test_source, test_constraint = _tool_summary_line(
152
+ "nac-test", cfg.tools.nac_test
153
+ )
154
+ lines.append(validate_line)
155
+ lines.append(test_line)
156
+ warnings = [
157
+ *tool_warnings(manifest, "nac-validate", cfg.tools.nac_validate),
158
+ *tool_warnings(manifest, "nac-test", cfg.tools.nac_test),
159
+ ]
160
+
161
+ if prewarm:
162
+ base_env = apply_no_color_env(no_color)
163
+ if validate_source.mode != "local":
164
+ argv = tools.build_prewarm_argv("nac-validate", validate_constraint)
165
+ code = runner.run_streaming(
166
+ argv, cwd=cfg.working_dir, env=base_env, no_color=no_color
167
+ )
168
+ if code != 0:
169
+ raise typer.Exit(code=code)
170
+ if test_source.mode != "local":
171
+ argv = tools.build_prewarm_argv("nac-test", test_constraint)
172
+ code = runner.run_streaming(
173
+ argv, cwd=cfg.working_dir, env=base_env, no_color=no_color
174
+ )
175
+ if code != 0:
176
+ raise typer.Exit(code=code)
177
+
178
+ echo_summary("Setup Summary", lines, no_color=no_color)
179
+ emit_warnings(warnings, no_color=no_color)
nac/cli/test.py ADDED
@@ -0,0 +1,63 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """`nac test` -- streams nac-test built from config."""
5
+
6
+ import typer
7
+
8
+ from nac import runner, tools
9
+ from nac.output import apply_no_color_env
10
+
11
+ from .main import app, get_config, get_no_color
12
+ from .options import Artifacts
13
+ from .passthrough import (
14
+ PASSTHROUGH_CONTEXT_SETTINGS,
15
+ PASSTHROUGH_OPTIONS_METAVAR,
16
+ show_help_and_exit,
17
+ wants_help,
18
+ )
19
+ from .versions import emit_warnings, get_manifest, note_on_failure, tool_warnings
20
+
21
+
22
+ @app.command(
23
+ name="test",
24
+ context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
25
+ options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
26
+ )
27
+ def test_(ctx: typer.Context, artifacts: Artifacts = False) -> None:
28
+ """Run nac-test against the configured data/templates/filters/output.
29
+
30
+ Extra arguments are passed through to `nac-test`.
31
+ """
32
+ cfg = get_config(ctx)
33
+ no_color = get_no_color(ctx)
34
+ log_path = cfg.working_dir / "test.txt" if artifacts else None
35
+ manifest = get_manifest(ctx, cfg)
36
+
37
+ if wants_help(ctx):
38
+ show_help_and_exit(
39
+ ctx,
40
+ tools.build_help_argv(
41
+ "nac-test",
42
+ tools.tool_constraint("nac-test", cfg.tools.nac_test, manifest),
43
+ ),
44
+ cwd=cfg.working_dir,
45
+ env=apply_no_color_env(no_color),
46
+ no_color=no_color,
47
+ tool_label="nac-test",
48
+ usage_as="nac test",
49
+ )
50
+
51
+ warnings = tool_warnings(manifest, "nac-test", cfg.tools.nac_test)
52
+ log_written = emit_warnings(warnings, no_color=no_color, log_path=log_path)
53
+
54
+ code = runner.run_streaming(
55
+ tools.build_test_argv(cfg, manifest) + ctx.args,
56
+ cwd=cfg.working_dir,
57
+ env=apply_no_color_env(no_color),
58
+ log_path=log_path,
59
+ append=log_written,
60
+ no_color=no_color,
61
+ )
62
+ note_on_failure("test", code, warnings, no_color=no_color, log_path=log_path)
63
+ raise typer.Exit(code=code)
nac/cli/validate.py ADDED
@@ -0,0 +1,90 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """`nac validate` -- optional targeted render, then nac-validate."""
5
+
6
+ import logging
7
+
8
+ import typer
9
+
10
+ from nac import runner, terraform, tools
11
+ from nac.output import GREEN, apply_no_color_env, color, echo_summary
12
+
13
+ from .main import app, get_config, get_no_color, resolve_engine_or_exit
14
+ from .options import Artifacts
15
+ from .passthrough import (
16
+ PASSTHROUGH_CONTEXT_SETTINGS,
17
+ PASSTHROUGH_OPTIONS_METAVAR,
18
+ show_help_and_exit,
19
+ wants_help,
20
+ )
21
+ from .versions import emit_warnings, get_manifest, note_on_failure, tool_warnings
22
+
23
+
24
+ @app.command(
25
+ context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
26
+ options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
27
+ )
28
+ def validate(ctx: typer.Context, artifacts: Artifacts = False) -> None:
29
+ """Render (if configured) then validate the resolved data paths.
30
+
31
+ Extra arguments are passed through to `nac-validate`.
32
+ """
33
+ cfg = get_config(ctx)
34
+ no_color = get_no_color(ctx)
35
+ log_path = cfg.working_dir / "validate.txt" if artifacts else None
36
+ manifest = get_manifest(ctx, cfg)
37
+ constraint = tools.tool_constraint("nac-validate", cfg.tools.nac_validate, manifest)
38
+
39
+ if wants_help(ctx):
40
+ show_help_and_exit(
41
+ ctx,
42
+ tools.build_help_argv("nac-validate", constraint),
43
+ cwd=cfg.working_dir,
44
+ env=apply_no_color_env(no_color),
45
+ no_color=no_color,
46
+ tool_label="nac-validate",
47
+ usage_as="nac validate",
48
+ )
49
+
50
+ warnings = tool_warnings(manifest, "nac-validate", cfg.tools.nac_validate)
51
+ log_written = emit_warnings(warnings, no_color=no_color, log_path=log_path)
52
+
53
+ if cfg.render is not None and cfg.render.target is not None:
54
+ engine, binary = resolve_engine_or_exit(cfg)
55
+ env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
56
+ quiet = not logging.getLogger().isEnabledFor(logging.INFO)
57
+ code = runner.run_streaming(
58
+ terraform.build_render_apply_argv(
59
+ binary, cfg.render.target, no_color=no_color
60
+ ),
61
+ cwd=cfg.working_dir,
62
+ env=env,
63
+ quiet=quiet,
64
+ log_path=log_path,
65
+ append=log_written,
66
+ no_color=no_color,
67
+ )
68
+ log_written = True
69
+ if code != 0:
70
+ raise typer.Exit(code=code)
71
+ if quiet:
72
+ echo_summary(
73
+ "Render Summary",
74
+ [
75
+ f"Render: {color(GREEN, 'OK', no_color=no_color)} "
76
+ f"({cfg.render.target})"
77
+ ],
78
+ no_color=no_color,
79
+ )
80
+
81
+ code = runner.run_streaming(
82
+ tools.build_validate_argv(cfg, manifest) + ctx.args,
83
+ cwd=cfg.working_dir,
84
+ env=apply_no_color_env(no_color),
85
+ log_path=log_path,
86
+ append=log_written,
87
+ no_color=no_color,
88
+ )
89
+ note_on_failure("validate", code, warnings, no_color=no_color, log_path=log_path)
90
+ raise typer.Exit(code=code)
nac/cli/versions.py ADDED
@@ -0,0 +1,92 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """Advisory checks of nac-validate/nac-test against the module's tested versions.
5
+
6
+ `validate`/`test` check the tool they run (and `setup` both) and print a
7
+ warning for versions outside the tested, semver-compatible range -- before
8
+ the tool runs, plus a short note afterwards if it failed. Never changes
9
+ exit codes.
10
+ """
11
+
12
+ from pathlib import Path
13
+ from typing import Literal, cast
14
+
15
+ import typer
16
+
17
+ from nac import tools
18
+ from nac.config import NacConfig
19
+ from nac.manifest import Manifest, load_manifest
20
+ from nac.output import YELLOW, color
21
+
22
+ _MANIFEST_KEY = "manifest"
23
+
24
+
25
+ def get_manifest(ctx: typer.Context, cfg: NacConfig) -> Manifest | None:
26
+ """Load (once per invocation) the manifest of the initialized module."""
27
+ if _MANIFEST_KEY not in ctx.meta:
28
+ ctx.meta[_MANIFEST_KEY] = load_manifest(cfg.working_dir)
29
+ return cast("Manifest | None", ctx.meta[_MANIFEST_KEY])
30
+
31
+
32
+ def tool_warnings(
33
+ manifest: Manifest | None,
34
+ tool: Literal["nac-validate", "nac-test"],
35
+ explicit: str | None,
36
+ ) -> list[str]:
37
+ """Warnings for nac-validate/nac-test.
38
+
39
+ Without an explicit `tools.*` constraint the tool is resolved to the
40
+ latest compatible release (see `tools.tool_constraint`), so there is
41
+ nothing to warn about. With one, the version is known only for a local
42
+ install or an exact pin -- a range resolved by uvx isn't checked.
43
+ """
44
+ if manifest is None or explicit is None or tool not in manifest.tools:
45
+ return []
46
+ source = tools.resolve_tool_source(tool, explicit)
47
+ version = source.version if source.mode == "local" else tools.exact_pin(explicit)
48
+ warning = manifest.check_tool(tool, version)
49
+ return [warning] if warning is not None else []
50
+
51
+
52
+ def emit_warnings(
53
+ warnings: list[str],
54
+ *,
55
+ no_color: bool,
56
+ log_path: Path | None = None,
57
+ append: bool = False,
58
+ ) -> bool:
59
+ """Print warnings to stderr and, if given, write them to log_path.
60
+
61
+ Returns whether log_path was written, i.e. whether the next
62
+ `run_streaming` call for the same artifact must append to it.
63
+ """
64
+ for warning in warnings:
65
+ typer.echo(color(YELLOW, f"Warning: {warning}", no_color=no_color), err=True)
66
+ if not warnings or log_path is None:
67
+ return False
68
+ log_path.parent.mkdir(parents=True, exist_ok=True)
69
+ with log_path.open("a" if append else "w", encoding="utf-8") as fh:
70
+ fh.writelines(f"Warning: {warning}\n" for warning in warnings)
71
+ return True
72
+
73
+
74
+ def note_on_failure(
75
+ command: str,
76
+ code: int,
77
+ warnings: list[str],
78
+ *,
79
+ no_color: bool,
80
+ log_path: Path | None = None,
81
+ ) -> None:
82
+ """After a failed run, point back at the version warnings printed earlier."""
83
+ if code == 0 or not warnings:
84
+ return
85
+ note = (
86
+ f"Note: `nac {command}` failed while using versions outside the tested "
87
+ "range (see warnings above); this may be related."
88
+ )
89
+ typer.echo(color(YELLOW, note, no_color=no_color), err=True)
90
+ if log_path is not None and log_path.is_file():
91
+ with log_path.open("a", encoding="utf-8") as fh:
92
+ fh.write(f"{note}\n")