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/config.py ADDED
@@ -0,0 +1,181 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ # ValidateConfig.schema / NacConfig.validate intentionally use the same
5
+ # names as nac.yaml's own keys; pydantic only warns because they shadow
6
+ # deprecated pydantic-v1 classmethods that this codebase never calls.
7
+ import warnings
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ import pydantic
12
+ import ruamel.yaml
13
+ from pydantic import BaseModel, ConfigDict, model_validator
14
+
15
+ from nac.exceptions import ConfigError
16
+
17
+ warnings.filterwarnings(
18
+ "ignore",
19
+ message=r'Field name ".*" in ".*" shadows an attribute in parent "BaseModel"',
20
+ category=UserWarning,
21
+ )
22
+
23
+ DEFAULT_CONFIG_PATH = Path("nac.yaml")
24
+
25
+
26
+ class DataConfig(BaseModel):
27
+ model_config = ConfigDict(extra="forbid")
28
+
29
+ paths: list[str] = ["data/"]
30
+ defaults: str | None = None
31
+
32
+
33
+ class RenderConfig(BaseModel):
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ target: str | None = None
37
+ output: str | None = None
38
+
39
+ @model_validator(mode="after")
40
+ def _target_requires_output(self) -> "RenderConfig":
41
+ if self.target is not None and self.output is None:
42
+ raise ValueError("render.output must be set when render.target is set")
43
+ return self
44
+
45
+
46
+ class ValidateConfig(BaseModel):
47
+ model_config = ConfigDict(extra="forbid")
48
+
49
+ schema: str | None = None # ty: ignore[invalid-attribute-override]
50
+ rules: list[str] | None = None
51
+
52
+
53
+ class TestConfig(BaseModel):
54
+ # Tells pytest not to try collecting this as a test class -- it's a
55
+ # config model that happens to be named after nac.yaml's `test:` key.
56
+ __test__ = False
57
+
58
+ model_config = ConfigDict(extra="forbid")
59
+
60
+ templates: str | None = None
61
+ filters: str | None = None
62
+ output: str = "tests/results"
63
+
64
+
65
+ class TerraformToolConfig(BaseModel):
66
+ model_config = ConfigDict(extra="forbid")
67
+
68
+ engine: Literal["tofu", "terraform"] | None = None
69
+ version: str | None = None
70
+
71
+
72
+ class ToolsConfig(BaseModel):
73
+ model_config = ConfigDict(extra="forbid")
74
+
75
+ terraform: TerraformToolConfig = TerraformToolConfig()
76
+ nac_validate: str | None = None
77
+ nac_test: str | None = None
78
+
79
+
80
+ class EnvConfig(BaseModel):
81
+ model_config = ConfigDict(extra="forbid")
82
+
83
+ required: list[str] = []
84
+
85
+
86
+ class NacConfig(BaseModel):
87
+ model_config = ConfigDict(extra="forbid")
88
+
89
+ working_dir: Path = Path(".")
90
+ data: DataConfig = DataConfig()
91
+ render: RenderConfig | None = None
92
+ validate: ValidateConfig = ValidateConfig() # ty: ignore[invalid-attribute-override]
93
+ test: TestConfig = TestConfig()
94
+ tools: ToolsConfig = ToolsConfig()
95
+ env: EnvConfig = EnvConfig()
96
+
97
+
98
+ def load_config(path: Path | str = DEFAULT_CONFIG_PATH) -> NacConfig:
99
+ """Load, validate, and resolve conventions for a nac.yaml file.
100
+
101
+ A missing file is treated as an empty config -- every field defaults,
102
+ so `nac` works with zero config present. Raises ConfigError (never a
103
+ raw pydantic/ruamel traceback) on malformed YAML or a schema
104
+ validation failure.
105
+ """
106
+ path = Path(path)
107
+ raw: object = {}
108
+ if path.is_file():
109
+ yaml = ruamel.yaml.YAML(typ="safe")
110
+ try:
111
+ with path.open("r", encoding="utf-8") as fh:
112
+ raw = yaml.load(fh)
113
+ except ruamel.yaml.YAMLError as exc:
114
+ raise ConfigError(f"Failed to parse {path}: {exc}") from exc
115
+
116
+ if raw is None:
117
+ raw = {}
118
+ if not isinstance(raw, dict):
119
+ raise ConfigError(
120
+ f"Config file {path} must contain a YAML mapping at the top level"
121
+ )
122
+
123
+ try:
124
+ cfg = NacConfig.model_validate(raw)
125
+ except pydantic.ValidationError as exc:
126
+ raise ConfigError(_format_validation_error(path, exc)) from exc
127
+
128
+ return _apply_filesystem_defaults(cfg, path)
129
+
130
+
131
+ def _format_validation_error(path: Path, exc: pydantic.ValidationError) -> str:
132
+ lines = [f"Invalid config file: {path}"]
133
+ for err in exc.errors():
134
+ loc = ".".join(str(part) for part in err["loc"]) or "<root>"
135
+ lines.append(f" {loc}: {err['msg']}")
136
+ return "\n".join(lines)
137
+
138
+
139
+ def _apply_filesystem_defaults(cfg: NacConfig, config_path: Path) -> NacConfig:
140
+ working_dir_abs = (config_path.parent / cfg.working_dir).resolve()
141
+
142
+ data = cfg.data
143
+ if data.defaults is None and (working_dir_abs / "defaults.yaml").is_file():
144
+ data = data.model_copy(update={"defaults": "defaults.yaml"})
145
+
146
+ test = cfg.test
147
+ if test.templates is None and (working_dir_abs / "tests" / "templates").is_dir():
148
+ test = test.model_copy(update={"templates": "tests/templates"})
149
+ if test.filters is None and (working_dir_abs / "tests" / "filters").is_dir():
150
+ test = test.model_copy(update={"filters": "tests/filters"})
151
+
152
+ return cfg.model_copy(
153
+ update={"working_dir": working_dir_abs, "data": data, "test": test}
154
+ )
155
+
156
+
157
+ def resolve_validate_data(cfg: NacConfig) -> list[str]:
158
+ """Data paths for `nac validate`.
159
+
160
+ `render.output` if `render.target` is set, else `data.paths`.
161
+ """
162
+ if cfg.render is not None and cfg.render.target is not None:
163
+ if cfg.render.output is None:
164
+ raise ConfigError("render.output must be set when render.target is set")
165
+ return [cfg.render.output]
166
+ return list(cfg.data.paths)
167
+
168
+
169
+ def resolve_test_data(cfg: NacConfig) -> list[str]:
170
+ """Data paths for `nac test`.
171
+
172
+ (`render.output` if `render` is configured, else `data.paths`) with
173
+ `data.defaults` appended whenever it's configured.
174
+ """
175
+ if cfg.render is not None:
176
+ base: list[str] = [cfg.render.output] if cfg.render.output is not None else []
177
+ else:
178
+ base = list(cfg.data.paths)
179
+ if cfg.data.defaults is not None:
180
+ base = [*base, cfg.data.defaults]
181
+ return base
nac/exceptions.py ADDED
@@ -0,0 +1,34 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+
5
+ class NacError(Exception):
6
+ """Base class for all nac errors."""
7
+
8
+
9
+ class ConfigError(NacError):
10
+ """Missing, malformed, or invalid nac.yaml."""
11
+
12
+
13
+ class EngineNotFoundError(NacError):
14
+ """An explicitly-configured tools.terraform.engine binary is not on PATH."""
15
+
16
+
17
+ class NoEngineAvailableError(NacError):
18
+ """Neither tofu nor terraform was found on PATH and no engine was configured."""
19
+
20
+
21
+ class BootstrapError(NacError):
22
+ """Base class for OpenTofu zero-config bootstrap failures."""
23
+
24
+
25
+ class UnsupportedPlatformError(BootstrapError):
26
+ """The current OS/architecture has no matching OpenTofu release asset."""
27
+
28
+
29
+ class BootstrapDeclinedError(BootstrapError):
30
+ """Installation was not confirmed and no binary is already cached."""
31
+
32
+
33
+ class ChecksumMismatchError(BootstrapError):
34
+ """The downloaded archive's SHA-256 didn't match the published checksum."""
nac/manifest.py ADDED
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """Tested-versions manifest shipped with a Network-as-Code Terraform module.
5
+
6
+ A module may ship a `nac/manifest.yaml` (next to the rest of its nac-specific
7
+ content: schema, rules, test templates) listing the nac-validate/nac-test
8
+ versions it was tested with. terraform/tofu and provider versions are not
9
+ part of it: the module's `versions.tf` already constrains those, and
10
+ Terraform enforces them itself. After `terraform init`, `nac` finds it via
11
+ `.terraform/modules/modules.json` and uses it purely as a hint: versions
12
+ outside the tested, semver-compatible range produce a warning, never an
13
+ error. A missing or unreadable manifest is silently ignored.
14
+ """
15
+
16
+ import json
17
+ import logging
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Literal
21
+
22
+ import pydantic
23
+ import ruamel.yaml
24
+ from packaging.specifiers import SpecifierSet
25
+ from packaging.version import InvalidVersion, Version
26
+ from pydantic import BaseModel, ConfigDict
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ MANIFEST_FILE = "nac/manifest.yaml"
31
+ MODULES_JSON = Path(".terraform") / "modules" / "modules.json"
32
+ _REGISTRY_HOSTS = ("registry.terraform.io/", "registry.opentofu.org/")
33
+
34
+
35
+ class _ManifestFile(BaseModel):
36
+ # Unknown keys are ignored so newer manifests keep working with older nac.
37
+ model_config = ConfigDict(extra="ignore", coerce_numbers_to_str=True)
38
+
39
+ schema_version: Literal[1] = pydantic.Field(alias="schema")
40
+ tools: dict[str, str] = {}
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Manifest:
45
+ """Tested versions for one module, keyed by tool name.
46
+
47
+ `module` is a human-readable label for messages, e.g.
48
+ `netascode/nac-nxos/nxos 0.3.0`.
49
+ """
50
+
51
+ module: str
52
+ tools: dict[str, str] = field(default_factory=dict)
53
+
54
+ def check_tool(self, name: str, actual: str | None) -> str | None:
55
+ """Warning message if `actual` is outside the tested range, else None."""
56
+ tested = self.tools.get(name)
57
+ if actual is None or tested is None:
58
+ return None
59
+ status = classify(actual, tested)
60
+ if status == "compatible":
61
+ logger.info(
62
+ "%s %s is newer than the version tested with %s (%s) but "
63
+ "semver-compatible",
64
+ name,
65
+ actual,
66
+ self.module,
67
+ tested,
68
+ )
69
+ if status != "untested":
70
+ return None
71
+ return (
72
+ f"{name} {actual} is outside the range tested with {self.module} "
73
+ f"(tested: {tested}, compatible: {compatible_range(tested)})"
74
+ )
75
+
76
+
77
+ def compatible_range(tested: str) -> str | None:
78
+ """Semver-compatible range starting at `tested`.
79
+
80
+ Upward-only, up to the next major -- or the next minor for 0.x, where
81
+ a minor bump may be breaking. None if `tested` isn't a valid version.
82
+ """
83
+ try:
84
+ version = Version(tested)
85
+ except InvalidVersion:
86
+ return None
87
+ upper = f"{version.major + 1}" if version.major > 0 else f"0.{version.minor + 1}"
88
+ return f">={version},<{upper}"
89
+
90
+
91
+ def classify(
92
+ actual: str, tested: str
93
+ ) -> Literal["tested", "compatible", "untested"] | None:
94
+ """How `actual` relates to `tested`; None if either isn't a valid version."""
95
+ spec = compatible_range(tested)
96
+ try:
97
+ actual_version = Version(actual)
98
+ except InvalidVersion:
99
+ return None
100
+ if spec is None:
101
+ return None
102
+ if actual_version == Version(tested):
103
+ return "tested"
104
+ if SpecifierSet(spec).contains(actual_version, prereleases=True):
105
+ return "compatible"
106
+ return "untested"
107
+
108
+
109
+ def load_manifest(working_dir: Path) -> Manifest | None:
110
+ """Find and parse the manifest of an initialized module in working_dir.
111
+
112
+ Considers every module in `.terraform/modules/modules.json` that ships
113
+ a manifest, preferring the shallowest (so a local wrapper module around
114
+ a netascode module still works). Returns None if there is none or it
115
+ can't be read -- the manifest is only ever a hint.
116
+ """
117
+ try:
118
+ raw = json.loads((working_dir / MODULES_JSON).read_text(encoding="utf-8"))
119
+ modules = raw["Modules"]
120
+ except (OSError, ValueError, KeyError, TypeError):
121
+ return None
122
+ if not isinstance(modules, list):
123
+ return None
124
+
125
+ candidates = [
126
+ m for m in modules if isinstance(m, dict) and m.get("Key") and m.get("Dir")
127
+ ]
128
+ candidates.sort(key=lambda m: str(m["Key"]).count("."))
129
+ for module in candidates:
130
+ path = working_dir / str(module["Dir"]) / MANIFEST_FILE
131
+ if path.is_file():
132
+ manifest = _parse_manifest(path, _module_label(module))
133
+ if manifest is not None:
134
+ return manifest
135
+ return None
136
+
137
+
138
+ def _module_label(module: dict[str, object]) -> str:
139
+ source = str(module.get("Source") or "")
140
+ version = str(module.get("Version") or "")
141
+ for host in _REGISTRY_HOSTS:
142
+ if source.startswith(host):
143
+ return f"{source[len(host) :]} {version}".strip()
144
+ return f"module.{module['Key']}"
145
+
146
+
147
+ def _parse_manifest(path: Path, label: str) -> Manifest | None:
148
+ yaml = ruamel.yaml.YAML(typ="safe")
149
+ try:
150
+ with path.open("r", encoding="utf-8") as fh:
151
+ data = _ManifestFile.model_validate(yaml.load(fh))
152
+ except (OSError, ruamel.yaml.YAMLError, pydantic.ValidationError) as exc:
153
+ logger.debug("Ignoring unreadable manifest %s: %s", path, exc)
154
+ return None
155
+ return Manifest(module=label, tools=dict(data.tools))
nac/output.py ADDED
@@ -0,0 +1,113 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """Shared terminal output formatting, matching nac-validate's own summary
5
+ box style (80-char `-` rule, bold header, green checkmarks) so a `nac`
6
+ invocation reads as one coherent report.
7
+
8
+ Color is on by default (matching a plain `terraform apply`); callers pass
9
+ an explicit `no_color` bool -- resolved once per invocation from the
10
+ `--no-color` flag / `NO_COLOR` env var in `nac.cli.main.get_no_color` --
11
+ rather than this module re-reading the environment itself."""
12
+
13
+ import os
14
+ import re
15
+ import shlex
16
+ import subprocess
17
+ import sys
18
+ from collections.abc import Mapping, Sequence
19
+
20
+ import typer
21
+
22
+ SEP_WIDTH = 80
23
+ BOLD = "\033[1m"
24
+ GREEN = "\033[92m"
25
+ YELLOW = "\033[93m"
26
+ DIM = "\033[2m"
27
+ _RESET = "\033[0m"
28
+
29
+ _ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
30
+
31
+
32
+ def color(code: str, text: str, *, no_color: bool = False) -> str:
33
+ """Wrap text in an ANSI color code, unless no_color is set.
34
+
35
+ Click strips these automatically when stdout isn't a TTY, so this
36
+ only needs to handle the explicit opt-out.
37
+ """
38
+ if no_color:
39
+ return text
40
+ return f"{code}{text}{_RESET}"
41
+
42
+
43
+ def echo_summary(title: str, lines: list[str], *, no_color: bool = False) -> None:
44
+ """Print a status box: bold title between `-` rules, one
45
+ green-checkmarked line per entry in `lines`."""
46
+ sep = "─" * SEP_WIDTH
47
+ typer.echo(f"\n{color(BOLD, sep, no_color=no_color)}")
48
+ typer.echo(color(BOLD, title, no_color=no_color))
49
+ typer.echo(sep)
50
+ for line in lines:
51
+ typer.echo(f" {color(GREEN, '✓', no_color=no_color)} {line}")
52
+ typer.echo(f"{sep}\n")
53
+
54
+
55
+ def format_command(argv: Sequence[str]) -> str:
56
+ """Render argv as a single copy-pasteable command line, quoted for the
57
+ current platform's shell (POSIX sh, or cmd.exe-style on Windows)."""
58
+ if sys.platform == "win32":
59
+ return subprocess.list2cmdline(argv)
60
+ return shlex.join(argv)
61
+
62
+
63
+ def echo_command(argv: Sequence[str], *, no_color: bool = False) -> None:
64
+ """Print the exact command nac is about to run, as a dim `$ ...` line.
65
+
66
+ Goes to stderr so it never mixes into a wrapped tool's stdout when that
67
+ is piped or redirected. stdout is flushed first so the line still lands
68
+ in order relative to output nac has already written there.
69
+ """
70
+ sys.stdout.flush()
71
+ typer.echo(color(DIM, f"$ {format_command(argv)}", no_color=no_color), err=True)
72
+
73
+
74
+ def utf8_env(base_env: Mapping[str, str]) -> dict[str, str]:
75
+ """Copy of base_env that makes Python child processes (nac-validate,
76
+ nac-test) emit UTF-8 when piped; on Windows they would otherwise use the
77
+ ANSI code page and crash on characters like box-drawing glyphs."""
78
+ env = dict(base_env)
79
+ env.setdefault("PYTHONUTF8", "1")
80
+ env.setdefault("PYTHONIOENCODING", "utf-8")
81
+ return env
82
+
83
+
84
+ def ensure_utf8_streams() -> None:
85
+ """Make our own stdout/stderr able to print the glyphs nac emits (─, ✓)
86
+ even when redirected on Windows, where the default is the ANSI code page."""
87
+ for stream in (sys.stdout, sys.stderr):
88
+ reconfigure = getattr(stream, "reconfigure", None)
89
+ if reconfigure is not None:
90
+ reconfigure(encoding="utf-8", errors="replace")
91
+
92
+
93
+ def strip_ansi(text: str) -> str:
94
+ """Strip ANSI escape sequences, e.g. before writing subprocess output
95
+ to a log file artifact (plan.txt) so it stays clean, human-diffable
96
+ text even when the same output was streamed to the terminal in color."""
97
+ return _ANSI_ESCAPE_RE.sub("", text)
98
+
99
+
100
+ def apply_no_color_env(
101
+ no_color: bool, base_env: Mapping[str, str] | None = None
102
+ ) -> dict[str, str]:
103
+ """Build a subprocess env dict, adding NO_COLOR when no_color is set.
104
+
105
+ Terraform/OpenTofu only ever look at the literal `-no-color` CLI flag
106
+ (never the NO_COLOR env var), so this is for forwarding the setting to
107
+ subprocesses that *do* honor the NO_COLOR convention -- nac-validate
108
+ and nac-test.
109
+ """
110
+ env = dict(base_env if base_env is not None else os.environ)
111
+ if no_color:
112
+ env["NO_COLOR"] = "1"
113
+ return env
nac/runner.py ADDED
@@ -0,0 +1,135 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ import codecs
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from contextlib import nullcontext
10
+ from pathlib import Path
11
+ from typing import cast
12
+
13
+ from nac.output import echo_command, strip_ansi, utf8_env
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _READ_SIZE = 4096
18
+
19
+
20
+ def _write_stdout(data: bytes) -> None:
21
+ """Pass the child's raw bytes through untouched.
22
+
23
+ Going via the binary buffer avoids the text layer's newline translation
24
+ (\r\n -> \r\r\n on Windows) and its locale encoding (cp1252 on Windows
25
+ cannot encode much of what terraform/tofu print).
26
+ """
27
+ sys.stdout.flush()
28
+ buffer = getattr(sys.stdout, "buffer", None)
29
+ if buffer is None: # replaced stdout without a binary layer
30
+ sys.stdout.write(data.decode("utf-8", errors="replace"))
31
+ sys.stdout.flush()
32
+ return
33
+ buffer.write(data)
34
+ buffer.flush()
35
+
36
+
37
+ def _log_env_overrides(env: dict[str, str]) -> None:
38
+ overrides = {k: v for k, v in env.items() if os.environ.get(k) != v}
39
+ if overrides:
40
+ logger.debug(
41
+ "Environment overrides: %s",
42
+ " ".join(f"{k}={v}" for k, v in sorted(overrides.items())),
43
+ )
44
+
45
+
46
+ def run_streaming(
47
+ cmd: list[str],
48
+ cwd: Path,
49
+ env: dict[str, str],
50
+ log_path: Path | None = None,
51
+ quiet: bool = False,
52
+ append: bool = False,
53
+ no_color: bool = False,
54
+ ) -> int:
55
+ """Run cmd as a subprocess, streaming merged stdout+stderr in real time.
56
+
57
+ stdin is inherited from the parent so interactive prompts (e.g.
58
+ terraform's apply confirmation) still work. cmd[0] must already be a
59
+ resolved path — this function does no binary resolution itself.
60
+
61
+ Output is read in raw chunks via os.read (not line-by-line) -- a
62
+ line-buffered `for line in proc.stdout` blocks until a newline shows
63
+ up, so a prompt like terraform's trailing "Enter a value: " (no
64
+ newline, then it waits on stdin) would otherwise sit unflushed and
65
+ invisible until the next newline arrives.
66
+
67
+ When quiet is True, output is captured instead of being streamed live;
68
+ it is only written to stdout if the command exits non-zero, so a caller
69
+ can pair this with its own short status line on success while still
70
+ surfacing the real output on failure.
71
+
72
+ Output written to log_path always has ANSI escape codes stripped --
73
+ regardless of whether the terminal-facing copy was colored -- so log
74
+ file artifacts (e.g. plan.txt) stay clean, human-diffable text.
75
+
76
+ By default log_path is opened in truncate mode, so a stale artifact
77
+ from a previous, separate invocation never leaks into new output. Pass
78
+ append=True when this call is a continuation of an earlier
79
+ run_streaming call within the same invocation that already truncated
80
+ the same log_path (e.g. nac validate's render step followed by the
81
+ nac-validate call itself), so both calls' output lands in one file in
82
+ order. append has no effect when log_path is None.
83
+
84
+ The exact command line is always echoed first (to stderr, never to
85
+ log_path, so artifacts like plan.json stay pure tool output -- see
86
+ `echo_command`); the environment variables nac set or changed for the
87
+ child are logged at DEBUG, since they can affect behavior too (e.g. the
88
+ tenv version pin) but would be noise on every run.
89
+ """
90
+ child_env = utf8_env(env)
91
+ echo_command(cmd, no_color=no_color)
92
+ _log_env_overrides(child_env)
93
+
94
+ if log_path is not None:
95
+ log_path.parent.mkdir(parents=True, exist_ok=True)
96
+
97
+ mode = "a" if append else "w"
98
+ # newline="" keeps the child's own line endings: on Windows it already
99
+ # emits \r\n, which default newline translation would turn into \r\r\n.
100
+ log_cm = (
101
+ log_path.open(mode, encoding="utf-8", newline="")
102
+ if log_path is not None
103
+ else nullcontext()
104
+ )
105
+ with log_cm as log_file:
106
+ proc = subprocess.Popen(
107
+ cmd,
108
+ cwd=cwd,
109
+ env=child_env,
110
+ stdout=subprocess.PIPE,
111
+ stderr=subprocess.STDOUT,
112
+ stdin=None,
113
+ bufsize=0,
114
+ )
115
+ if proc.stdout is None:
116
+ raise RuntimeError("subprocess stdout was not piped")
117
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
118
+ buffered: list[bytes] = []
119
+ fd = proc.stdout.fileno()
120
+ while True:
121
+ raw = os.read(fd, _READ_SIZE)
122
+ if not raw:
123
+ break
124
+ if quiet:
125
+ buffered.append(raw)
126
+ else:
127
+ _write_stdout(raw)
128
+ if log_file is not None:
129
+ log_file.write(strip_ansi(decoder.decode(raw)))
130
+ proc.wait()
131
+ if proc.returncode is None:
132
+ raise RuntimeError("subprocess did not report an exit code")
133
+ if quiet and proc.returncode != 0:
134
+ _write_stdout(b"".join(buffered))
135
+ return cast(int, proc.returncode)