Visible stderr in check_output/check_call Failures Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make CalledProcessErrors raised by check_output/check_call
show their captured stderr in str(error), so a default (uncaught)
traceback reveals why a command failed instead of just its exit code —
fixing a diagnostic gap identified while investigating a CI failure in a
downstream consumer of this library.
Architecture: A CalledProcessError subclass (in subprocess.py,
subclassing the stdlib exception) overriding __str__ to append decoded,
tail-capped stderr. Raised from both branches of check_output;
check_call inherits the fix automatically since it delegates to
check_output. No caller elsewhere in the repo changes.
Tech Stack: Pure stdlib subprocess/tempfile; no new dependencies.
Implements docs/superpowers/specs/2026-08-13-subprocess-stderr-visibility-design.md.
Global Constraints
- No mocks — all new tests run real commands (
bash,sleep,echo), perdocs/contributing.md. error.stderrmust remainbytesin thesuppress_stderr=Truepath and followtextmode in thesuppress_stderr=Falsepath — unchanged from today.databricks.py:457'serror.stderr.decode()anddatabricks.py:218-224'serror.stdoutbyte-substring checks must keep working untouched.- The raised exception must remain an instance of stdlib
subprocess.CalledProcessError(via subclassing), so every existingexcept CalledProcessError/with suppress(CalledProcessError)/@retry(...)site indatabricks.py/onepassword.pykeeps matching. make formatandmake testmust pass after the change.
Task 1: Add the CalledProcessError subclass and overload fix
Files:
- Modify: src/decorative_secrets/subprocess.py
- [ ] Step 1: Alias the stdlib exception import
Change:
from subprocess import (
PIPE,
CalledProcessError,
CompletedProcess,
run,
)
to:
from subprocess import (
PIPE,
CompletedProcess,
run,
)
from subprocess import CalledProcessError as _CalledProcessError
- [ ] Step 2: Add the subclass
Immediately after the imports (before get_default_shell), add:
_STDERR_TAIL_LENGTH: int = 10_000
class CalledProcessError(_CalledProcessError):
"""
Identical to `subprocess.CalledProcessError`, except that `str(error)`
includes the tail end of captured stderr, so a default traceback
shows the cause of a command's failure instead of just its exit code.
"""
def __str__(self) -> str:
message: str = super().__str__()
stderr: str | bytes | None = self.stderr
if isinstance(stderr, bytes):
stderr = stderr.decode("utf-8", errors="ignore")
stderr = (stderr or "").strip()
if stderr:
if len(stderr) > _STDERR_TAIL_LENGTH:
stderr = f"...{stderr[-_STDERR_TAIL_LENGTH:]}"
message = f"{message} Stderr:\n{stderr}"
return message
- [ ] Step 3: Raise the subclass in the
suppress_stderr=Truebranch
Replace (current lines ~159-162):
except CalledProcessError as error:
stderr.seek(0)
error.stderr = stderr.read().encode("utf-8", errors="ignore")
raise
with:
except _CalledProcessError as error:
stderr.seek(0)
raise CalledProcessError(
error.returncode,
error.cmd,
output=error.output,
stderr=stderr.read().encode("utf-8", errors="ignore"),
) from None
- [ ] Step 4: Raise the subclass in the
suppress_stderr=Falsebranch
Wrap the else: branch's run(...) call (currently lines ~163-174) in a
try/except:
else:
try:
completed_process = run(
args_,
capture_output=True,
check=True,
cwd=cwd or None,
input=input,
env=env,
text=text,
shell=shell_,
timeout=timeout,
)
except _CalledProcessError as error:
raise CalledProcessError(
error.returncode,
error.cmd,
output=error.output,
stderr=error.stderr,
) from None
- [ ] Step 5: Fix the first
@overload's missingsuppress_stderr
Add suppress_stderr: bool = True to the text: Literal[True] overload
(currently lines 43-54), matching the other two overloads' parameter
order (cwd, input, env, suppress_stderr, shell, timeout, echo).
- [ ] Step 6: Run
make format
From the repo root: make format
Expected: hatch fmt --formatter && hatch fmt --linter && hatch run mypy
all pass with no errors.
Task 2: Rewrite and extend test_subprocess.py
Files:
- Modify: tests/test_subprocess.py
- [ ] Step 1: Replace the broken
test_check_output
Replace the current test_check_output (lines 82-103) — whose
pytest.raises(AssertionError) calls are no-ops (never used as a context
manager) and whose sys.stderr swap can't capture a child process's
fd-level stderr — with:
def test_check_call_suppresses_and_attaches_stderr(
capfd: pytest.CaptureFixture[str],
) -> None:
"""
`check_call` suppresses a failing command's stderr from the console,
attaches it to `error.stderr` as bytes, and includes it in
`str(error)`.
"""
with pytest.raises(CalledProcessError) as exc_info:
check_call(("bash", "-c", "echo oops >&2; exit 3"))
assert capfd.readouterr().err == ""
error: CalledProcessError = exc_info.value
assert isinstance(error, subprocess.CalledProcessError)
assert isinstance(error.stderr, bytes)
assert b"oops" in error.stderr
assert error.returncode == 3 # noqa: PLR2004
assert "oops" in str(error)
Add import subprocess and CalledProcessError to the existing
from decorative_secrets.subprocess import (...) block at the top of the
file (already imports check_call, check_output, get_default_shell,
list2cmdline).
- [ ] Step 2: Add
test_check_output_error_str_includes_stderr
Same assertion shape, called via check_output directly rather than
through check_call, to cover the function the fix actually lives in:
def test_check_output_error_str_includes_stderr() -> None:
"""
`check_output` includes captured stderr in `str(error)` on failure.
"""
with pytest.raises(CalledProcessError) as exc_info:
check_output(("bash", "-c", "echo oops >&2; exit 1"))
assert "oops" in str(exc_info.value)
- [ ] Step 3: Add
test_check_output_unsuppressed_error_str_includes_stderr
Covers the suppress_stderr=False branch:
def test_check_output_unsuppressed_error_str_includes_stderr() -> None:
"""
With `suppress_stderr=False`, a failing command's stderr is still
included in `str(error)`.
"""
with pytest.raises(CalledProcessError) as exc_info:
check_output(
("bash", "-c", "echo oops >&2; exit 1"),
suppress_stderr=False,
)
assert "oops" in str(exc_info.value)
- [ ] Step 4: Add
test_called_process_error_str_truncates_long_stderr
def test_called_process_error_str_truncates_long_stderr() -> None:
"""
`str(error)` truncates stderr longer than the tail-length cap,
keeping only the end of the output (where the actionable error
usually is) and marking the truncation.
"""
with pytest.raises(CalledProcessError) as exc_info:
check_output(
("bash", "-c", "yes error-line | head -c 20000 >&2; exit 1")
)
message: str = str(exc_info.value)
assert "..." in message
assert len(message) < 15_000 # noqa: PLR2004
- [ ] Step 5: Confirm the bytes-stdout contract is unchanged
Verify (add a test if not already covered by existing ones) that
check_output(..., text=False) failures still populate error.output as
bytes — this plan does not touch .output/.stdout handling, but the
test suite should assert it explicitly given the raise sites were
rewritten.
- [ ] Step 6: Run
make test
From the repo root: make test
Expected: hatch fmt --check && hatch run mypy && hatch test -c all pass,
coverage fail_under = 80 satisfied.
Task 3: Release
Files:
- Modify: pyproject.toml
- [ ] Step 1: Bump the version
Change version = "0.13.3" to version = "0.14.0" (minor —
behavior-compatible, additive).
-
[ ] Step 2: Confirm
make format && make testpass at the bumped version -
[ ] Step 3: Release (user-approved)
Per repo convention (make distribute runs hatch build && hatch
publish) — not run as part of this plan without separate explicit
permission. Once published, downstream consumers pinned with a
compatible-release specifier (e.g. ~=0.12) pick up 0.14.0 automatically
on their next fresh environment resolution; no other action is required
from this repo.