feat(build): live-stream Linux build output; ns7zip via in-guest clone
Lint / pssa (push) Failing after 26s
Lint / python (push) Successful in 52s

- SshTransport.run_streaming: incremental channel recv, forwards
  stdout/stderr live (no more one-block-at-end). _linux_build uses it
  with PYTHONUNBUFFERED so the guest python flushes promptly.
- _tail_lines split out for failure messages (no double print).
- Windows live streaming deferred (TODO 8.2) — pypsrp execute_ps
  returns only at completion.
- build-ns7zip.yml: use-git-clone:'true' (in-guest), repo-url ->
  https://gitea.emulab.it/... (guest-reachable; host SSH alias was
  host-only). Requires GiteaPAT in the LocalSystem keyring or a public
  nsis-plugin-ns7zip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Simone
2026-05-17 20:51:48 +02:00
parent 29c4ef09bc
commit 4f22d11a84
5 changed files with 110 additions and 10 deletions
+16 -4
View File
@@ -112,6 +112,11 @@ def _report_build_output(stdout: str, stderr: str) -> str:
sys.stderr.write(stderr if stderr.endswith("\n") else stderr + "\n")
sys.stderr.flush()
click.echo("[build run] ----- end build output -----")
return _tail_lines(stdout, stderr)
def _tail_lines(stdout: str, stderr: str) -> str:
"""Last few output lines for a failure message (no printing)."""
combined = (stderr or "").strip() or (stdout or "").strip()
lines = combined.splitlines()
return " | ".join(lines[-5:]) if lines else "(no build output)"
@@ -189,13 +194,20 @@ def _linux_build(
f"export {k}={_sh_quote(v)}; " for k, v in extra_env.items()
)
cmd = build_command or "make"
full_cmd = f"{env_prefix}cd {_sh_quote(workdir)} && {cmd}"
# PYTHONUNBUFFERED so the guest python flushes promptly and the
# streamed output is timely rather than emitted in one block.
full_cmd = (
f"{env_prefix}export PYTHONUNBUFFERED=1; "
f"cd {_sh_quote(workdir)} && {cmd}"
)
click.echo(f"[build run] running build (env vars redacted): cd {workdir} && {cmd}")
result = t.run(full_cmd, check=False)
tail = _report_build_output(result.stdout, result.stderr)
click.echo("[build run] ----- build output (live) -----")
result = t.run_streaming(full_cmd, check=False)
click.echo("[build run] ----- end build output -----")
if result.returncode != 0:
raise click.ClickException(
f"build command failed (exit {result.returncode}): {tail}"
"build command failed "
f"(exit {result.returncode}): {_tail_lines(result.stdout, result.stderr)}"
)
if skip_artifact:
+60
View File
@@ -14,6 +14,8 @@ on stderr output. With paramiko there is no equivalent issue.
from __future__ import annotations
import contextlib
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
@@ -123,6 +125,64 @@ class SshTransport:
raise TransportCommandError(result.returncode, result.stdout, result.stderr)
return result
def run_streaming(
self, command: str, *, check: bool = True, timeout: float | None = None
) -> SshResult:
"""Run ``command`` streaming stdout/stderr live to this process.
Unlike :meth:`run` (which blocks on a full channel read), output
is forwarded chunk-by-chunk as it arrives so long builds show
progress instead of one block at the end. Captured output is
still returned in :class:`SshResult`.
"""
client = self._connect()
try:
chan = client.get_transport().open_session() # type: ignore[union-attr]
if timeout:
chan.settimeout(timeout)
chan.exec_command(command)
except Exception as exc:
raise TransportConnectError(f"SSH exec_command failed: {exc}") from exc
out_parts: list[str] = []
err_parts: list[str] = []
try:
while True:
progressed = False
while chan.recv_ready():
d = chan.recv(65536).decode("utf-8", errors="replace")
out_parts.append(d)
sys.stdout.write(d)
sys.stdout.flush()
progressed = True
while chan.recv_stderr_ready():
d = chan.recv_stderr(65536).decode("utf-8", errors="replace")
err_parts.append(d)
sys.stderr.write(d)
sys.stderr.flush()
progressed = True
if (
chan.exit_status_ready()
and not chan.recv_ready()
and not chan.recv_stderr_ready()
):
break
if not progressed:
time.sleep(0.05)
rc = chan.recv_exit_status()
except Exception as exc:
raise TransportConnectError(f"SSH stream failed: {exc}") from exc
finally:
with contextlib.suppress(Exception):
chan.close()
result = SshResult(
stdout="".join(out_parts), stderr="".join(err_parts), returncode=rc
)
if check and not result.ok:
raise TransportCommandError(result.returncode, result.stdout, result.stderr)
return result
def copy(self, local_path: str | Path, remote_path: str) -> None:
"""Upload via SFTP."""
client = self._connect()