Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Clean SIGPIPE handling for piped output** — `bcli <cmd> | head`,
`| grep -m 1`, and similar pipe-truncating consumers now terminate
the CLI silently, matching `cat` and `grep` conventions, instead of
emitting a `BrokenPipeError: [Errno 32] Broken pipe` traceback at
interpreter shutdown. Implemented as a new `bcli_cli.app:main`
console-script entry point that installs `SIGPIPE -> SIG_DFL` on
POSIX with a `BrokenPipeError` safety net for Windows.
- **Hyphenated saved-query param names** — the workflow template
resolver now accepts hyphens in identifiers, so references like
`${{ params.vendor-no }}` substitute correctly. Previously the regex
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Issues = "https://github.com/igor-ctrl/bcli/issues"
Documentation = "https://github.com/igor-ctrl/bcli/tree/main/docs"

[project.scripts]
bcli = "bcli_cli.app:app"
bcli = "bcli_cli.app:main"
bcli-mcp = "bcli_mcp:main"

[project.optional-dependencies]
Expand Down
34 changes: 33 additions & 1 deletion src/bcli_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,5 +198,37 @@ def _emit_command_summary() -> None:
pass


def main() -> None:
"""Console-script entry point.

Wraps the Typer ``app`` with a SIGPIPE handler so that ``bcli ... | head``
and similar pipe-truncating consumers terminate the CLI silently —
matching the Unix idiom of ``cat``, ``grep`` and friends — instead of
surfacing ``BrokenPipeError`` at interpreter shutdown.

On Windows the ``signal.SIGPIPE`` constant is absent; the safety-net
``try`` below catches the error in that path.
"""
import signal

if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_DFL)

try:
app()
except BrokenPipeError:
# Closing stdout/stderr before exit prevents Python's atexit flush
# from re-triggering the same error on a now-dead pipe.
try:
sys.stdout.close()
except Exception:
pass
try:
sys.stderr.close()
except Exception:
pass
sys.exit(0)


if __name__ == "__main__":
app()
main()
65 changes: 65 additions & 0 deletions tests/test_cli/test_pipe_handling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Regression test: piping CLI output to a closing consumer must not
surface BrokenPipeError on stderr.

Running ``bcli ... | head -N`` was emitting a traceback like:

Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' ...>
BrokenPipeError: [Errno 32] Broken pipe

at interpreter shutdown when the formatter wrote past the point at which
``head`` had closed its end of the pipe. The fix in ``bcli_cli.app.main``
installs a SIGPIPE handler so the process exits silently like ``cat``.

The test exercises the real entry point in a subprocess against a small
in-memory record set — avoiding any BC network call — and asserts that
the stderr produced under truncation contains no traceback or
``BrokenPipeError`` marker.
"""

from __future__ import annotations

import signal
import subprocess
import sys

import pytest


pytestmark = pytest.mark.skipif(
not hasattr(signal, "SIGPIPE"),
reason="SIGPIPE only meaningful on POSIX",
)


def test_help_piped_to_head_does_not_leak_brokenpipe() -> None:
"""``bcli --help | head -1`` should exit clean.

``--help`` is the shortest path that exercises the entry point and
writes more than one line to stdout — enough for ``head -1`` to
close the pipe before the writer finishes.
"""
help_proc = subprocess.Popen(
[sys.executable, "-m", "bcli_cli.app", "--help"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
head_proc = subprocess.Popen(
["head", "-1"],
stdin=help_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)

assert help_proc.stdout is not None
help_proc.stdout.close() # let head's EOF propagate
head_proc.communicate(timeout=15)
_, help_stderr = help_proc.communicate(timeout=15)

stderr_text = help_stderr.decode("utf-8", errors="replace")

assert "BrokenPipeError" not in stderr_text, (
f"BrokenPipeError leaked to stderr:\n{stderr_text}"
)
assert "Exception ignored" not in stderr_text, (
f"Python interpreter teardown noise leaked to stderr:\n{stderr_text}"
)
Loading