Skip to content

Question: truncate() succeeds on SeekableReader although the stream reports it is not writable, and the writeable() override is misspelled so io never consults it #645

Description

@JarryShaw

This is a question rather than a bug report. Making truncate raise would be an API break for anyone relying on it, so the call is the maintainer's, not something to fold into a fix. What follows is the evidence for the question, plus a second, smaller observation that turned up alongside it.

The io contract

From the CPython documentation for io.IOBase.writable(), fetched from https://docs.python.org/3/library/io.html:

Return True if the stream supports writing. If False, write() and truncate() will raise OSError.

The pure-Python reference implementation carries the same wording, at _pyio.py:451-456 (CPython 3.14.7, .../lib/python3.14/_pyio.py):

def writable(self):
    """Return a bool indicating whether object was opened for writing.

    If False, write() and truncate() will raise OSError.
    """
    return False

and — more to the point — the reference implementation enforces it mechanically. _BufferedIOMixin.truncate, the base that every buffered reader and writer in _pyio inherits, at _pyio.py:791-804:

def truncate(self, pos=None):
    self._checkClosed()
    self._checkWritable()

    # Flush the stream.  We're mixing buffered I/O with lower-level I/O,
    # and a flush may be necessary to synch both views of the current
    # file state.
    self.flush()

    if pos is None:
        pos = self.tell()
    return self.raw.truncate(pos)

truncate is an IOBase-level contract point: neither the RawIOBase nor the BufferedIOBase section of the io documentation mentions it, and in the ABC table it appears only as an IOBase stub method alongside fileno and seek.

What SeekableReader does

SeekableReader.truncate at pcapkit/corekit/io.py:314-335 is a complete override that performs no writability check — there is no _checkWritable() and no equivalent anywhere in it. It returns self._buffer_size at line 335 and never raises except for a negative size.

Measured on origin/main (375e9d411), CPython 3.14.7, tree asserted as the repository's own:

r = SeekableReader(io.BytesIO(b'hello world'))
r.writable()            # False
r.truncate()            # 0        <- returns; does not raise
r.truncate(4)           # 4        <- returns; does not raise

r.write(b'x')           # UnsupportedOperation: write
r.writelines([b'x'])    # UnsupportedOperation: write

So of the two methods the contract names, write honours it and truncate does not. pcapkit.utilities.exceptions.UnsupportedOperation is declared at pcapkit/utilities/exceptions.py:477 as class UnsupportedOperation(BaseError, io.UnsupportedOperation), hence a genuine OSError subclass — write and writelines really do satisfy the contract, which makes truncate the odd one out rather than the class being uniformly lax.

Sites:

pcapkit/corekit/io.py:314-335     truncate    -- returns, no writability check
pcapkit/corekit/io.py:337-340     writeable   -- returns False
pcapkit/corekit/io.py:342-345     writelines  -- raise UnsupportedOperation('write')
pcapkit/corekit/io.py:458-471     write       -- raise UnsupportedOperation('write')

The question

Should truncate raise on SeekableReader, given it reports itself non-writable?

Arguments both ways, as far as they can be judged from outside the project's intent:

  • For raising: it is what the documented contract says, and what _pyio's own buffered base enforces. A caller that checks writable() before calling truncate — which is exactly what the contract invites — currently gets a surprise either way round.
  • Against: truncate here does not write to the underlying stream at all. It resizes SeekableReader's internal buffer window, which is a private structure, so the operation is arguably not a "write" in the sense the contract means. If that is the intent, then the honest repair is to the docstrings and possibly the method's name, not to make it raise.

Worth noting that the class's own docstrings quote both contract sentences and the seekable one is satisfied: seekable() at pcapkit/corekit/io.py:305-308 returns True, and its docstring's "If False, seek(), tell() and truncate() will raise OSError" is therefore consistent with truncate being available. It is only the writable half that is contradicted.

truncate also has no production callers — grep -rn '\.truncate(' --include='*.py' finds it only in tests/corekit/test_io.py:59 and :147-149. So whatever is decided, nothing inside this repository breaks; the API-break risk is entirely about external callers. That is what makes this a question worth asking before anyone acts on it.

Separately: writeable is misspelled, so the io machinery never consults it

The io API spells the method writable. SeekableReader defines writeable, with an e:

pcapkit/corekit/io.py:337      def writeable(self) -> 'bool':

That is not an override of anything. Verified by execution:

'writeable' in SeekableReader.__dict__      # True
'writable'  in SeekableReader.__dict__      # False    <- nothing overridden
r.writeable()                               # False    (the method defined here)
r.writable()                                # False    (inherited from io.BufferedReader)

Both happen to return False, so there is no observable divergence today — io.BufferedReader does not override writable() either, so it resolves to IOBase.writable, which unconditionally returns False. The coincidence is what hides the problem: the method defined in this file is dead with respect to the io protocol, and anything that checks writability the standard way — io, shutil, a third-party caller — reads the inherited value and never sees this one. If SeekableReader's base class or its write-ability ever changed, editing writeable would silently have no effect.

A repository-wide grep finds the misspelling is the only spelling anyone here uses:

tests/corekit/test_io.py:146     self.assertFalse(reader.writeable())
pcapkit/corekit/io.py:337        def writeable(self) -> 'bool':

Nothing in the repository calls the real writable(). So tests/corekit/test_io.py:146 asserts the dead method and locks in the misspelling, and the same test goes on at :147-149 to call truncate(None), truncate(6) and truncate(2) with no expectation that any of them raises — the suite blesses the current behaviour of both halves of this issue.

Unlike the contract question above, this half looks like a straightforward defect rather than a judgement call: whatever is decided about truncate, a method intended to answer the io protocol's writability query should be spelled the way the protocol spells it.

Also checked, and not reproducible

One further finding was reported alongside these — that SeekableReader.close() leaves CPython's finaliser to fail, producing Exception ignored in: <function IOBase.__del__ ...> noise at interpreter shutdown. It does not reproduce on CPython 3.14.7, so it is not being filed, and this note exists so nobody re-investigates it from scratch.

Seven variants were run as subprocesses with stderr captured separately — close() then falling off the end; close() then del plus gc.collect(); never closing; closing twice; buffer_save=True; a real open(..., 'rb') file as the raw stream; and stream_closing=False. All produced empty stderr and exit code 0, including under -W always::ResourceWarning.

The stream_closing=False variant does produce a genuine state split, and is still silent:

raw = io.BytesIO(b'hello')
r = SeekableReader(raw, stream_closing=False)
r.close()
r.closed                                 # True    <- the override at io.py:80-83
io.BufferedReader.closed.__get__(r)      # False   <- the C-level descriptor disagrees
del r; gc.collect()                      # stderr: '' , exit 0

close() at pcapkit/corekit/io.py:147-167 indeed never calls super().close(), so the gap is real. It stays invisible because IOBase.__del__ reads self.closed through ordinary attribute lookup, which resolves to the override and short-circuits. If the noise was genuinely observed, it was on a different interpreter version or through a path none of these variants reached — e.g. an exception raised inside flush() or self._stream.close() during close() itself, which cannot be arranged without editing the library.

Notes

Activity

  1. added
    questionIssues asking how something works rather than reporting a defect
    on Sep 22, 2026
  2. JarryShaw commented on Sep 22, 2026

    @JarryShaw
    OwnerAuthor

    Answering rather than acting on this, since it is your call. Line numbers below are main at 0c7f2b7c9; #663 shifts them but touches none of these methods.

    The contract, fetched rather than recalled

    _pyio.py on this machine (CPython 3.14.7, .../lib/python3.14/_pyio.py), IOBase.writable at lines 451-456:

    def writable(self):
        """Return a bool indicating whether object was opened for writing.
    
        If False, write() and truncate() will raise OSError.
        """
        return False

    _checkWritable at 458-463, and the base every buffered reader in _pyio inherits — _BufferedIOMixin.truncate at 791-804:

    def truncate(self, pos=None):
        self._checkClosed()
        self._checkWritable()
        ...
        return self.raw.truncate(pos)

    _pyio.BufferedReader overrides neither, so it inherits both. https://docs.python.org/3/library/io.html agrees:

    Return True if the stream supports writing. If False, write() and truncate() will raise OSError.

    and its ABC table lists truncate, writable and seekable only as IOBase stub/mixin methods — RawIOBase/BufferedIOBase/TextIOBase do not restate them. So truncate is an IOBase-level contract point gated by both writable() and seekable(), which is why the seekable half of your quoted docstring is satisfied while the writable half is not.

    One wrinkle that cuts the other way, worth having on the record: the C-accelerated docstring is narrower than both of the above and has dropped the clause entirely.

    >>> io.IOBase.writable.__doc__
    'Return whether object was opened for writing.\n\nIf False, write() will raise OSError.'
    >>> _pyio.IOBase.writable.__doc__
    'Return a bool indicating whether object was opened for writing.\n\nIf False, write() and truncate() will raise OSError.\n'
    >>> io.BufferedReader.truncate.__doc__
    None
    

    CPython's own sources are therefore not uniform on the wording.

    But the C implementation does enforce it

    This is the part that changes the weight of the argument, and it is not what the docstring above would lead you to expect. A real open(path, 'rb') is a C _io.BufferedReader, and it raises:

    >>> f = open(path, 'rb'); type(f)
    <class '_io.BufferedReader'>
    >>> f.writable()
    False
    >>> f.truncate()
    io.UnsupportedOperation: truncate          # isinstance(exc, OSError) -> True
    

    Same on _pyio.BufferedReader over a non-writable raw stream (UnsupportedOperation: File or stream is not writable.). So this is not a pure-Python-reference nicety the accelerated path skips — every ordinary read-only file object in CPython raises here today. "The contract says so" is a materially stronger argument than it would be if only _pyio honoured it.

    Where that leaves SeekableReader

    truncate (pcapkit/corekit/io.py:317-378) has no writability check; write (:509) and writelines (:385) both raise UnsupportedOperation('write'), and pcapkit.utilities.exceptions.UnsupportedOperation (pcapkit/utilities/exceptions.py:477) really is an OSError subclass. So two of the three writability-gated methods already honour the contract and truncate is the outlier — this is not a class that is uniformly lax. _pyio.BufferedReader itself overrides neither — the truncate at _pyio.py:791 belongs to _BufferedIOMixin (declared at :765), and the overrides at :1239/:1270 are BufferedWriter's — so a read-only buffered reader inherits both the check and the refusal. And SeekableReader.truncate has no production callers: grep -rn '\.truncate(' --include='*.py' pcapkit/ returns nothing at all, and repo-wide every hit is in tests/corekit/test_io.py. Nothing inside the package breaks either way; the risk is entirely external callers.

    Which way I would go, for what it is worth: raise, via the same check write/writelines already use. It matches the documented contract, it matches what real BufferedReader objects actually do on both implementations, and it makes the class internally consistent. I hold that loosely, though — the counter-argument is real and the C docstring above is evidence for it. truncate here resizes a private lookback window and never touches the stream, so it is arguably not a "write" in the sense the contract means; if that is the intent, the honest repair is to stop quoting the borrowed sentence in the docstring rather than to change the behaviour. Both are defensible and the empirical finding does not settle it, it only makes the raising side heavier.

    If you do go that way, note it interacts with #622/#633: truncate is the method whose content behaviour was just fixed, and making it raise would make those tests unreachable rather than wrong.

    The writeable misspelling is a different kind of thing

    That half does not look like a judgement call. Confirmed by execution:

    'writeable' in SeekableReader.__dict__   -> True
    'writable'  in SeekableReader.__dict__   -> False
    SeekableReader.writable is io.IOBase.writable  -> True
    

    So the method defined at :380 overrides nothing, and anything checking writability the standard way — io, shutil, a third-party caller — reads the inherited IOBase.writable and never sees it. Both return False, so there is no symptom; that is coincidence rather than correctness, and it means editing writeable would silently have no effect if the base class or the write-ability ever changed. tests/corekit/test_io.py:146 asserts the dead method, so the misspelling is locked in by the suite.

    Worth spelling it the way the protocol spells it whichever way the truncate question goes — but since renaming it is the natural moment to decide whether writable() should stay False, I have left both halves alone in #663 rather than pre-empting you. Leaving this open.

  3. JarryShaw commented on Sep 22, 2026

    @JarryShaw
    OwnerAuthor

    Owner decision: fix it

    In the owner's words: "fix it".

    So SeekableReader.truncate() succeeding on a stream that reports itself unwritable is to be corrected, and the API break that entails is accepted.

    Restating the contract this is measured against, from _pyio.py on this machine (CPython 3.14.7, .../lib/python3.14/_pyio.py) — IOBase.writable at lines 451-456:

    def writable(self):
        """Return a bool indicating whether object was opened for writing.
    
        If False, write() and truncate() will raise OSError.
        """
        return False

    with _checkWritable at 458-463 and _BufferedIOMixin.truncate at 791-804, which is the base every buffered reader in _pyio inherits.

    Not yet dispatched, and the reason is a file collision rather than a doubt: the fix lands in pcapkit/corekit/io.py, which is owned by open PR #663 (fix/io-position-bookkeeping, head a38fd2cb1). Two agents editing one file clobber each other silently, so this waits for #663 to merge and then goes out immediately. Line numbers in this issue's earlier analysis were taken on main at 0c7f2b7c9; #663 shifts them but touches none of these methods.

    Also queued behind #663 for the same reason: #643 and #644, both of which #663 already closes, and #644's four min(size, self._buffer_cur - 1) sites at io.py:212, :375, :405 and :492.

  4. added
    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)
    on Sep 23, 2026
  5. added this to the 1.5 milestone on Oct 6, 2026
  6. moved this to Done in PyPCAPKiton Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)questionIssues asking how something works rather than reporting a defect

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions