quopri.decode() Crashes with a Raw binascii TypeError on None (tested)
A non-blocking raw stream signals "no data ready yet" by returning None from readinto() -- distinct from the empty read that means real end-of-stream. We measured two layers of mishandling stacked on top of each other: first, io.BufferedReader.read() with no size argument reads until EOF by looping internally -- but on the very first call, if zero bytes have accumulated yet and the raw read returns None, it returns None directly with no retry. Second, quopri.decode()'s normal code path (used whenever the binascii C accelerator is available, which is effectively always in CPython) is just a2b_qp(input.read(), header=header) -- no None check, no retry. The None reaches the C-level binascii.a2b_qp() function directly, which raises TypeError: argument should be bytes, buffer or ASCII string, not 'NoneType' -- an error with no mention of quopri, files, or streams at all, on a payload that is valid and complete one read call away.
1. A real, valid quoted-printable payload behind one transient None
import io, quopri
class FlakyRaw(io.RawIOBase):
def __init__(self, payload):
self.calls = 0
self.sent = False
self.payload = payload
def readable(self):
return True
def readinto(self, b):
self.calls += 1
if self.calls == 1:
return None # transient: no data ready yet
if not self.sent:
self.sent = True
n = len(self.payload)
b[:n] = self.payload
return n # the real, complete payload
return 0 # real EOF after that
payload = b"Hello=20World=0A"
raw = FlakyRaw(payload)
br = io.BufferedReader(raw)
out = io.BytesIO()
try:
quopri.decode(br, out)
print("OUTPUT:", out.getvalue())
except Exception as e:
print("CRASHED:", type(e).__name__, "--", e)
print("raw calls made:", raw.calls)
CRASHED: TypeError -- argument should be bytes, buffer or ASCII string, not 'NoneType'
raw calls made: 1
Measured: the payload is valid and complete, one read call away, and only one raw read happened before the crash. io.BufferedReader.read() (no size argument) returned None directly on its first call rather than retrying, and quopri.decode()'s fast path handed that None straight to the C-accelerated binascii.a2b_qp(), which has no idea quopri or a file is even involved.
2. The fix: drain into a real buffer first, retrying on None
def read_retrying_on_none(buffered_reader, chunk_size=65536):
out = io.BytesIO()
while True:
chunk = buffered_reader.read(chunk_size)
if chunk is None:
continue # retry: not ready yet, not real EOF
if not chunk:
break # real EOF
out.write(chunk)
out.seek(0)
return out
raw2 = FlakyRaw(payload)
br2 = io.BufferedReader(raw2)
safe_input = read_retrying_on_none(br2)
out2 = io.BytesIO()
quopri.decode(safe_input, out2)
print("safe output:", out2.getvalue())
safe output: b'Hello World\n'
Measured: resolving the None/EOF distinction before the bytes reach quopri.decode() decodes the payload correctly. If you ever see TypeError: argument should be bytes, buffer or ASCII string, not 'NoneType' coming out of code that calls quopri.decode() on a file-like object, the real cause is almost certainly a non-blocking stream, not a bad argument you passed directly.
Frequently asked questions
Is this documented behavior?
quopri's documentation says decode() reads from a binary input file object and writes to a binary output file object; it says nothing about None or non-blocking streams. We measured that a None-returning read() reaches binascii.a2b_qp() unchecked and crashes with a TypeError that names the wrong problem.
Why does read() with no argument return None instead of retrying?
We measured that io.BufferedReader.read() loops internally to reach real EOF, but only once it has at least one byte buffered. On the very first call, if the underlying raw readinto() returns None before any data has accumulated, read() has nothing to return except that same None -- there's no partial progress yet to fall back on.
Does the pure-Python fallback (no binascii) behave the same way?
No -- quopri.decode() only falls back to its pure-Python loop (while line := input.readline():) when the binascii C accelerator isn't available, which doesn't happen in a standard CPython build. That fallback uses readline(), which collapses None to b'' the same way the already-confirmed http.client/smtplib/poplib trio does -- a different, already-documented failure mode, not this one.
Is this related to the other None/EOF findings?
Yes -- same family already confirmed in json.load(), base64.encode()/decode(), shutil.copyfileobj, and others: a correctly-returned non-blocking None gets mishandled one layer above io itself. quopri.decode() is the first case in the family where the None reaches a C-accelerated function (binascii.a2b_qp()) with no Python-level type check at all, producing an error message with zero mention of quopri, io, or streams.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in quopri.decode(), reading a genuinely valid, complete quoted-printable payload through an io.BufferedReader over a custom non-blocking RawIOBase whose readinto() returns None once before returning the real bytes. PlainHeaders is our extension; Python's quopri, io, and binascii modules are built in.