Skip to content

Commit d0dd682

Browse files
committed
fix: prefix external command output for better log clarity
Most log output uses the package name as a prefix (via FromagerLogRecord) so it is possible to tell which package is being processed when a message is logged. However, external command output (like uv) lacked the package name on continuation lines, making it difficult to grep for all output related to a specific package. This fix adds the package name to every line of external command output by manually prefixing each line. The package name is retrieved from the log context using a new helper function and added to each continuation line, enabling grep to find all lines related to a specific package (e.g., `grep 'numpy:'`). Implementation: - Add get_log_prefix() helper in log.py to extract package name + version - Refactor FromagerLogRecord.getMessage() to use the helper - Use shlex.join() for command formatting in external_commands.py - Single logger call combining command info and output - str.replace() with f-string to add prefixes to each line - Hybrid .format() and f-string approach for Python 3.11 compatibility Benefits: - Every line has package name prefix for grep - Parallel builds: Single log call prevents line interleaving - Simple implementation with no code duplication - No extra visual markers - clean, consistent output Example output: numpy-1.0.0: command failed with exit code 1: uv pip install numpy numpy-1.0.0: Resolving dependencies... numpy-1.0.0: Error: Could not find package Tests: - Added test_external_command_output_prefix() to verify prefixing - Uses pytest fixtures to capture and validate log output - Verifies every line includes the package name for greppability Fixes: #753 Co-authored-by: Claude <claude@anthropic.com> Signed-off-by: Ioannis Angelakopoulos <iangelak@redhat.com>
1 parent 1fd83c3 commit d0dd682

3 files changed

Lines changed: 100 additions & 15 deletions

File tree

src/fromager/external_commands.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import typing
88
from io import TextIOWrapper
99

10+
from . import log
11+
1012
logger = logging.getLogger(__name__)
1113

1214
HERE = pathlib.Path(__file__).absolute().parent
@@ -98,8 +100,30 @@ def run(
98100
stdin=stdin,
99101
)
100102
output = completed.stdout.decode("utf-8") if completed.stdout else ""
103+
104+
# Prefix output lines with package name/version for greppability
105+
formatted_output = None
106+
if output:
107+
prefix = log.get_log_prefix()
108+
if prefix:
109+
# Add prefix to all lines for greppability
110+
formatted_output = "{}: {}".format(
111+
prefix,
112+
output.replace("\n", f"\n{prefix}: "),
113+
)
114+
else:
115+
formatted_output = output
116+
101117
if completed.returncode != 0:
102-
logger.error("%s failed with %s", cmd, output)
118+
# Log complete error message with command, exit code, and output
119+
output_to_log = "\n" + formatted_output if formatted_output else ""
120+
logger.error(
121+
"command failed with exit code %d: %s%s",
122+
completed.returncode,
123+
shlex.join(cmd),
124+
output_to_log,
125+
)
126+
103127
err_type = subprocess.CalledProcessError
104128
if network_isolation:
105129
# Look for a few common messages that mean there is a network
@@ -113,5 +137,9 @@ def run(
113137
if substr in output:
114138
err_type = NetworkIsolationError
115139
raise err_type(completed.returncode, cmd, output)
116-
logger.debug("output: %s", output)
140+
141+
# Log command output with visual prefix to distinguish from fromager output
142+
if formatted_output:
143+
logger.debug(formatted_output)
144+
117145
return output

src/fromager/log.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@
1717
version_ctxvar: contextvars.ContextVar[Version] = contextvars.ContextVar("version")
1818

1919

20+
def get_log_prefix() -> str | None:
21+
"""Get the log prefix based on current context (requirement and version).
22+
23+
Returns:
24+
- "name-version" if both requirement and version are available
25+
- "name" if only requirement is available
26+
- None if no context is available
27+
"""
28+
try:
29+
req = requirement_ctxvar.get()
30+
except LookupError:
31+
return None
32+
else:
33+
try:
34+
version = version_ctxvar.get()
35+
except LookupError:
36+
return req.name
37+
else:
38+
return f"{req.name}-{version}"
39+
40+
2041
@contextlib.contextmanager
2142
def req_ctxvar_context(
2243
req: Requirement, version: Version | None = None
@@ -53,17 +74,10 @@ class FromagerLogRecord(logging.LogRecord):
5374

5475
def getMessage(self) -> str: # noqa: N802
5576
msg = super().getMessage()
56-
try:
57-
req = requirement_ctxvar.get()
58-
except LookupError:
59-
return msg
60-
else:
61-
try:
62-
version = version_ctxvar.get()
63-
except LookupError:
64-
return f"{req.name}: {msg}"
65-
else:
66-
return f"{req.name}-{version}: {msg}"
77+
prefix = get_log_prefix()
78+
if prefix:
79+
return f"{prefix}: {msg}"
80+
return msg
6781

6882

6983
class ThreadLogFilter(logging.Filter):

tests/test_external_commands.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import logging
12
import os
23
import pathlib
34
import subprocess
45
import typing
56
from unittest import mock
67

78
import pytest
9+
from packaging.requirements import Requirement
10+
from packaging.version import Version
811

9-
from fromager import external_commands
12+
from fromager import external_commands, log
1013

1114

1215
def test_external_commands_environ() -> None:
@@ -29,7 +32,10 @@ def test_external_commands_log_file(tmp_path: pathlib.Path) -> None:
2932
assert "test\n" == file_contents
3033

3134

32-
@mock.patch("subprocess.run", return_value=mock.Mock(returncode=0))
35+
@mock.patch(
36+
"subprocess.run",
37+
return_value=mock.Mock(returncode=0, stdout=b"test output\n"),
38+
)
3339
@mock.patch(
3440
"fromager.external_commands.network_isolation_cmd",
3541
return_value=["/bin/unshare", "--net", "--map-current-user"],
@@ -85,3 +91,40 @@ def test_external_commands_network_isolation_real() -> None:
8591
)
8692
exc = typing.cast(subprocess.CalledProcessError, e.value)
8793
assert exc.returncode == 1
94+
95+
96+
def test_external_command_output_prefix(caplog: pytest.LogCaptureFixture) -> None:
97+
"""Test that external command output is prefixed with package name on each line."""
98+
req = Requirement("test-package==1.0.0")
99+
version = Version("1.0.0")
100+
101+
# Set up logging context with requirement and version
102+
token_req = log.requirement_ctxvar.set(req)
103+
token_ver = log.version_ctxvar.set(version)
104+
105+
try:
106+
with caplog.at_level(logging.DEBUG, logger="fromager.external_commands"):
107+
# Run a command that produces multi-line output
108+
external_commands.run(["echo", "-e", "line1\\nline2\\nline3"])
109+
110+
# Get the last debug log record (the output message)
111+
output_rec = caplog.records[-1]
112+
message = output_rec.getMessage()
113+
114+
# Verify that each line has the package name prefix
115+
expected_prefix = "test-package-1.0.0: "
116+
assert message.startswith(expected_prefix), (
117+
f"Message should start with '{expected_prefix}'"
118+
)
119+
120+
# Check that all lines have the prefix
121+
lines = message.split("\n")
122+
for line in lines:
123+
if line: # Skip empty lines
124+
assert line.startswith(expected_prefix), (
125+
f"Line '{line}' should start with '{expected_prefix}'"
126+
)
127+
finally:
128+
# Clean up context variables
129+
log.requirement_ctxvar.reset(token_req)
130+
log.version_ctxvar.reset(token_ver)

0 commit comments

Comments
 (0)