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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions