From 3005916f3da4ee4277087c59316016eb482a7e8a Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Tue, 16 Jun 2026 11:44:40 +0000 Subject: [PATCH] narrow is_stdin() to documented fileno() failure modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare 'except Exception' in is_stdin() swallowed every error that fileno() could raise, including ones that signal real bugs elsewhere (NameError, MemoryError, etc.) and ones that no caller expects the function to handle (KeyboardInterrupt inherits from BaseException so it still propagates, but SystemExit inherits from BaseException too and would also be caught — which we never wanted). The actual documented failure modes for file.fileno() on a file-like object are: - AttributeError: object has no fileno attribute at all (plain BytesIO, custom streams, anything that doesn't expose a real fd); - io.UnsupportedOperation: stream types like StringIO that explicitly disallow fileno() — this is a subclass of OSError, so catching OSError transitively covers it; - ValueError: 'I/O operation on closed file' for closed file objects (a closed tempfile, a closed BytesIO, etc.); - OSError: low-level errors on a corrupted/closed fd (EBADF, EINVAL, etc.). Narrow the handler to (OSError, ValueError, AttributeError), keep the same return-False semantics for the not-stdin case, and add a short comment listing the four documented failure modes. Adds a TestIsStdin class to tests/test_uploads.py with five unit tests: no fileno attribute, closed file, StringIO, BytesIO, and a real os.pipe() fd that is not stdin. The last test patches sys.stdin via monkeypatch with a surrogate so the comparison side is deterministic; pytest's own capture machinery replaces sys.stdin with a DontReadFromInput whose fileno() raises io.UnsupportedOperation, which would otherwise make the test depend on the runner. --- httpie/uploads.py | 11 ++++++++- tests/test_uploads.py | 52 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/httpie/uploads.py b/httpie/uploads.py index 4a993b3a25..9594eee954 100644 --- a/httpie/uploads.py +++ b/httpie/uploads.py @@ -87,7 +87,16 @@ def wrapped(*args, **kwargs): def is_stdin(file: IO) -> bool: try: file_no = file.fileno() - except Exception: + except (OSError, ValueError, AttributeError): + # fileno() can fail in a few documented ways: + # - AttributeError: object has no fileno attribute (e.g. BytesIO, + # custom streams, anything that doesn't expose a real fd); + # - io.UnsupportedOperation: stream types like StringIO that + # explicitly disallow fileno() (subclass of OSError); + # - ValueError: "I/O operation on closed file" for closed + # file objects; + # - OSError: low-level errors on a corrupted/closed fd. + # In all of these cases the object is not stdin, so return False. return False else: return file_no == sys.stdin.fileno() diff --git a/tests/test_uploads.py b/tests/test_uploads.py index e6bb80ac70..cdec8bacdf 100644 --- a/tests/test_uploads.py +++ b/tests/test_uploads.py @@ -401,3 +401,55 @@ def test_multiple_request_bodies_from_file_by_path(self, httpbin): ) assert r.exit_status == ExitStatus.ERROR assert 'from multiple files' in r.stderr + + +class TestIsStdin: + """`is_stdin` should return False (and not raise) for any object whose + `fileno()` call fails for a documented reason (no attribute, closed file, + or non-fd stream like StringIO).""" + def test_is_stdin_returns_false_for_plain_object_without_fileno(self): + from httpie.uploads import is_stdin + + class NoFileno: + def read(self, *_args, **_kwargs): + return b'' + + assert is_stdin(NoFileno()) is False + + def test_is_stdin_returns_false_for_closed_file(self): + from httpie.uploads import is_stdin + import tempfile + f = tempfile.TemporaryFile() + f.close() + assert is_stdin(f) is False + + def test_is_stdin_returns_false_for_stringio(self): + from httpie.uploads import is_stdin + import io + assert is_stdin(io.StringIO('hello')) is False + + def test_is_stdin_returns_false_for_bytesio(self): + from httpie.uploads import is_stdin + import io + assert is_stdin(io.BytesIO(b'hello')) is False + + def test_is_stdin_returns_false_for_other_real_file(self, monkeypatch): + from httpie.uploads import is_stdin + import io + import os + import tempfile + # Patch sys.stdin with a known surrogate so we control both sides + # of the comparison; pytest's own capture machinery replaces sys.stdin + # with a DontReadFromInput whose fileno() raises io.UnsupportedOperation, + # which is fragile to depend on in a unit test. + stdin_surrogate = io.BytesIO() + stdin_surrogate.fileno = lambda: 4242 # any value that won't collide + monkeypatch.setattr('httpie.uploads.sys.stdin', stdin_surrogate) + # is_stdin(our surrogate) should be True + assert is_stdin(stdin_surrogate) is True + # is_stdin(any other real fd) should be False + r, w = os.pipe() + try: + assert is_stdin(os.fdopen(r, 'r')) is False + finally: + os.close(w)