Why am I getting this warning?

I have a script which ends in the usual fashion:

if __name__ == "__main__":
    sys.exit(main())

If I run that script like so:

% python ... | head

I get predictable errors about the broken pipe:

Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "/Users/skip/src/csvprogs/csvprogs/hull.py", line 144, in <module>
    sys.exit(main())
             ^^^^^^
  File "/Users/skip/src/csvprogs/csvprogs/hull.py", line 128, in main
    wtr.writerow(row)
  File "/Users/skip/miniconda3/envs/python312/lib/python3.12/csv.py", line 164, in writerow
    return self.writer.writerow(self._dict_to_list(rowdict))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
BrokenPipeError: [Errno 32] Broken pipe
Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'>
BrokenPipeError: [Errno 32] Broken pipe

This output is understandable, but in situations where a downstream pipeline element cuts it off at the knees, I don’t need/want that traceback. I embellished the script with this little context manager (similar to many examples in the contextlib docs):

@contextmanager
def swallow_exceptions(exceptions):
    "catch and swallow the tuple of exceptions"
    try:
        yield None
    except exceptions:
        pass
    finally:
        pass

like so:

if __name__ == "__main__":
    with swallow_exceptions((BrokenPipeError, KeyboardInterrupt)):
        sys.exit(main())

Running it a second time gets rid of most of the error output, but not all of it:

% python ... | head  
...
Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'>
BrokenPipeError: [Errno 32] Broken pipe

This is a simple single-threaded Python script (running using Conda Python 3.12.3 on a Mac, if that matters). I’ve clearly attempted to suppress the BrokenPipeError, but only partially succeeded.

Looking through the 3.12 source I find what I think to be the culprit in .../Python/errors.c, but don’t understand the logic which goes into reaching this spot.

What am I doing wrong to keep my script from complete silence in the face of a broken pipe?

There’s a final call to sys.std{...}.flush() somewhere in Python’s shutdown sequence. I suspect it’s that which is failing (after exiting the context manager).

Here’s an example that seems to fit with my theory. The last line is what makes or breaks it.

import sys
import time
import contextlib


def main():
    while True:
        time.sleep(0.1)
        print(1, flush=True)


if __name__ == "__main__":
    with contextlib.suppress((BrokenPipeError, KeyboardInterrupt)):
        main()
    sys.stdout = None

There is a way to handle this described in the signal module documentation.

Ah, that makes sense. Sort of. It still puzzles me that the exception has been handled in the except clause, but apparently the SIGPIPE interrupt remains unhandled (see @saaketp’s reply). Hasn’t the code which raised the BrokenPipeError effectively already handled the interrupt? After all, I would think the low level SIGPIPE handler would be the code which raises the exception caught in my Python code in the first place.

I messed around a little trying to see if I could truly suppress the message. First, I tried setting up a signal handler in my finally clause:

@contextmanager
def swallow_exceptions(exceptions):
    "catch and swallow the tuple of exceptions"
    try:
        yield None
    except exceptions:
        pass
    finally:
        if BrokenPipeError in exceptions:
            # SIGPIPE yet to be handled?
            def handler(signum, frame):
                print('Signal handler called with signal', signum, file=sys.stderr)
            signal.signal(signal.SIGPIPE, handler)

That had no effect at all:

% python ... | head -10
...
Signal handler called with signal 13
Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'>
BrokenPipeError: [Errno 32] Broken pipe

Presumably, I set up the signal handler too late. I tried establishing it in the try clause:

@contextmanager
def swallow_exceptions(exceptions):
    "catch and swallow the tuple of exceptions"
    try:
        if BrokenPipeError in exceptions:
            # SIGPIPE yet to be handled?
            def handler(signum, frame):
                print('Signal handler called with signal', signum, file=sys.stderr)
            signal.signal(signal.SIGPIPE, handler)
        yield None
    except exceptions:
        pass
    finally:
        pass

That duplicated the call to my handler and still failed to suppress the message!

% python ... | head -10
...
Signal handler called with signal 13
Signal handler called with signal 13
Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'>
BrokenPipeError: [Errno 32] Broken pipe

Y’all are definitely on the right track, but it almost seems like the process is seeing SIGPIPE twice, one time which results in catching the BrokenPipeError exception, one after all the usual exception catching machinery has gone away.

I won’t bore you with the output, but adding the signal.signal call to the except clause also failed to change the output. Again, it would seem to have been added too late in the game.

Is it possible that something inside the SIGPIPE handler is causing a second SIGPIPE? If the only effect of the handler were to sys.exit(0) would it still do the same thing?

I don’t think it’s the same exception. I think you’re getting a second one on that implicit sys.stdout.flush() that happens at interpreter shutdown. This is roughly what my understanding is of what is happening under the lid:

# your own code here
# ...
if __name__ == "__main__":
    with swallow_exceptions((BrokenPipeError, KeyboardInterrupt)):
        # Raises+catches BrokenPipeError then moves on (without
        # getting to calling sys.exit())
        sys.exit(main())

# --- Implicit shutdown behaviour ---
# You didn't write this, but it's effectively always there.
# This cannot be put under your swallow_exceptions() guard.
sys.stdout.flush()  # raises another BrokenPipeError