ElementTree.parse() 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 xml.etree.ElementTree.parse() mishandles this exactly like several other stdlib consumers already documented: a single transient None, even with a completely valid, well-formed XML document waiting on the very next read call, crashes parsing with ParseError: no element found: line 1, column 0 -- an error message that reads like the input is empty or malformed, when it is neither.

1. A real, valid XML document behind one transient None

import io
import xml.etree.ElementTree as ET

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, well-formed XML
        return 0                   # real EOF after that

payload = b"<root><item>hello</item></root>"
raw = FlakyRaw(payload)
br = io.BufferedReader(raw)
try:
    tree = ET.parse(br)
    print("root tag:", tree.getroot().tag)
except Exception as e:
    print("CRASHED:", type(e).__name__, e)
print("raw calls made:", raw.calls)
CRASHED: ParseError no element found: line 1, column 0
raw calls made: 1

Measured: ET.parse() made exactly one read call, got None, and raised ParseError claiming no element was found at the very start of the document -- the document is not empty and is not malformed; it is sitting completely intact one read call away. The error message gives no indication that the real cause is a transient, retryable signal rather than a content problem.

2. Contrast: plain BufferedReader.read() on the identical raw stream

raw0 = FlakyRaw(b"irrelevant for this check")
br0 = io.BufferedReader(raw0)
print("plain read():", repr(br0.read(5)))
plain read(): None

Measured: the io layer ET.parse() reads through preserves the None/EOF distinction correctly. ET.parse() itself never checks for None before treating its read result as either "more XML bytes" or "end of input" -- the same gap already confirmed in shutil.copyfileobj, gzip/bz2/lzma, and tarfile/zipfile.

3. 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 = FlakyRaw(payload)
safe_buf = drain_retrying_on_none(raw2)
tree = ET.parse(safe_buf)
print("safe root tag:", tree.getroot().tag)
print("safe root text:", tree.getroot()[0].text)
safe root tag: root
safe root text: hello

Measured: resolving the None/EOF distinction before the bytes ever reach ET.parse() parses the document correctly. ET.parse() itself has no option to retry on a transient None -- the retry has to happen one layer below, at the binary BufferedReader level.

Frequently asked questions

Is this documented behavior?

The xml.etree.ElementTree documentation for parse() does not mention non-blocking streams or a None read result at all; it is written assuming a blocking file object that returns either data or b'' for EOF. We measured that a None-returning source produces an error that blames the document's content instead of naming the real cause.

Why does the error say 'no element found' instead of something about None?

We measured that ET.parse() feeds whatever its underlying reader returns directly into the expat C parser, which treats the None the same way it would treat genuinely empty input at the very start of parsing -- expat has no way to distinguish 'nothing yet' from 'nothing ever,' so it reports the same error either way.

Does ET.iterparse() have the same problem?

We measured ET.parse() directly, which reads the entire input before parsing begins. iterparse() incrementally feeds the parser as it reads, and would be expected to hit the identical expat-level confusion on the same None, though with a different point of failure since it processes events as data arrives rather than all at once.

Is this related to the other None/EOF findings?

Yes -- same family already confirmed in shutil.copyfileobj, gzip/bz2/lzma, and tarfile/zipfile: a correctly-returned non-blocking None gets collapsed into an EOF-like or content-error signal one layer above io itself. ElementTree.parse() is another independently-confirmed instance, with its own distinct, misleading error message.

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