plistlib.load() Crashes on a Non-Blocking 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 that plistlib.load(), which needs a seekable stream to detect whether the plist is XML or binary format, crashes on the transient None with AttributeError: 'NoneType' object has no attribute 'startswith' -- an internal-looking error that gives no hint the real cause is a retryable non-blocking signal rather than a malformed or unreadable plist.

1. A real, valid plist behind one transient None

import io, plistlib

class SeekableFlakyRaw(io.RawIOBase):
    def __init__(self, payload):
        self.calls = 0
        self.payload = payload
        self.pos = 0
    def readable(self):
        return True
    def seekable(self):
        return True
    def seek(self, offset, whence=0):
        if whence == 0: self.pos = offset
        elif whence == 1: self.pos += offset
        elif whence == 2: self.pos = len(self.payload) + offset
        return self.pos
    def tell(self):
        return self.pos
    def readinto(self, b):
        self.calls += 1
        if self.calls == 1:
            return None            # transient: no data ready yet
        n = min(len(b), len(self.payload) - self.pos)
        if n <= 0:
            return 0
        b[:n] = self.payload[self.pos:self.pos + n]
        self.pos += n
        return n

payload = plistlib.dumps({"key": "value"})
raw = SeekableFlakyRaw(payload)
try:
    data = plistlib.load(io.BufferedReader(raw))
    print("data:", data)
except Exception as e:
    print("CRASHED:", type(e).__name__, e)
print("raw calls made:", raw.calls)
CRASHED: AttributeError 'NoneType' object has no attribute 'startswith'
raw calls made: 1

Measured: the plist is valid and complete, one read call away. plistlib.load()'s format-detection step peeks at the start of the stream to decide XML vs. binary format, calling .startswith() on whatever it read -- when that read returns None, the .startswith() call itself raises AttributeError, an error that looks like an internal library bug rather than anything related to stream readiness.

2. The fix: drain into a real buffer first, retrying on None

def drain_retrying_on_none(raw_stream, chunk_size=65536):
    out = io.BytesIO()
    br = io.BufferedReader(raw_stream)
    while True:
        chunk = br.read(chunk_size)
        if chunk is None:
            continue              # retry: not ready yet, not EOF
        if not chunk:
            break                  # real EOF
        out.write(chunk)
    out.seek(0)
    return out

raw2 = SeekableFlakyRaw(payload)
safe_buf = drain_retrying_on_none(raw2)
print("safe data:", plistlib.load(safe_buf))
safe data: {'key': 'value'}

Measured: resolving the None/EOF distinction before the bytes reach plistlib.load() parses the plist correctly.

Frequently asked questions

Is this documented behavior?

The plistlib documentation for load() does not mention non-blocking streams or a None read result. We measured that a None-returning source produces an internal-looking AttributeError instead of a clear diagnosis.

Why startswith specifically?

We measured that plistlib's format auto-detection reads a small chunk from the start of the stream and calls .startswith() on it to check for the XML plist header; a None result has no such method, so the AttributeError fires there, before any actual plist parsing begins.

Is this related to the other None/EOF findings?

Yes -- same family already confirmed in shutil.copyfileobj, gzip/bz2/lzma, tarfile/zipfile, TextIOWrapper.readline(), ElementTree.parse(), tomllib.load(), and email.message_from_binary_file(): a correctly-returned non-blocking None gets mishandled one layer above io itself, each stdlib module failing in its own way.

Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in plistlib.load(), reading a genuinely valid plist through a seekable io.BufferedReader over a custom non-blocking RawIOBase whose readinto() returns None once before returning the real plist bytes. PlainHeaders is our extension; Python's plistlib and io modules are built in.