You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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):
defwritable(self):
"""Return a bool indicating whether object was opened for writing. If False, write() and truncate() will raise OSError. """returnFalse
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:
deftruncate(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()
ifposisNone:
pos=self.tell()
returnself.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() # Falser.truncate() # 0 <- returns; does not raiser.truncate(4) # 4 <- returns; does not raiser.write(b'x') # UnsupportedOperation: writer.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.
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:
That is not an override of anything. Verified by execution:
'writeable'inSeekableReader.__dict__# True'writable'inSeekableReader.__dict__# False <- nothing overriddenr.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:
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-83io.BufferedReader.closed.__get__(r) # False <- the C-level descriptor disagreesdelr; 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.
This is a question rather than a bug report. Making
truncateraise 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
iocontractFrom the CPython documentation for
io.IOBase.writable(), fetched from https://docs.python.org/3/library/io.html:The pure-Python reference implementation carries the same wording, at
_pyio.py:451-456(CPython 3.14.7,.../lib/python3.14/_pyio.py):and — more to the point — the reference implementation enforces it mechanically.
_BufferedIOMixin.truncate, the base that every buffered reader and writer in_pyioinherits, at_pyio.py:791-804:truncateis anIOBase-level contract point: neither theRawIOBasenor theBufferedIOBasesection of theiodocumentation mentions it, and in the ABC table it appears only as anIOBasestub method alongsidefilenoandseek.What
SeekableReaderdoesSeekableReader.truncateatpcapkit/corekit/io.py:314-335is a complete override that performs no writability check — there is no_checkWritable()and no equivalent anywhere in it. It returnsself._buffer_sizeat 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:So of the two methods the contract names,
writehonours it andtruncatedoes not.pcapkit.utilities.exceptions.UnsupportedOperationis declared atpcapkit/utilities/exceptions.py:477asclass UnsupportedOperation(BaseError, io.UnsupportedOperation), hence a genuineOSErrorsubclass —writeandwritelinesreally do satisfy the contract, which makestruncatethe odd one out rather than the class being uniformly lax.Sites:
The question
Should
truncateraise onSeekableReader, given it reports itself non-writable?Arguments both ways, as far as they can be judged from outside the project's intent:
_pyio's own buffered base enforces. A caller that checkswritable()before callingtruncate— which is exactly what the contract invites — currently gets a surprise either way round.truncatehere does not write to the underlying stream at all. It resizesSeekableReader'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
seekableone is satisfied:seekable()atpcapkit/corekit/io.py:305-308returnsTrue, and its docstring's "IfFalse,seek(),tell()andtruncate()will raiseOSError" is therefore consistent withtruncatebeing available. It is only thewritablehalf that is contradicted.truncatealso has no production callers —grep -rn '\.truncate(' --include='*.py'finds it only intests/corekit/test_io.py:59and: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:
writeableis misspelled, so theiomachinery never consults itThe
ioAPI spells the methodwritable.SeekableReaderdefineswriteable, with ane:That is not an override of anything. Verified by execution:
Both happen to return
False, so there is no observable divergence today —io.BufferedReaderdoes not overridewritable()either, so it resolves toIOBase.writable, which unconditionally returnsFalse. The coincidence is what hides the problem: the method defined in this file is dead with respect to theioprotocol, and anything that checks writability the standard way —io,shutil, a third-party caller — reads the inherited value and never sees this one. IfSeekableReader's base class or its write-ability ever changed, editingwriteablewould silently have no effect.A repository-wide grep finds the misspelling is the only spelling anyone here uses:
Nothing in the repository calls the real
writable(). Sotests/corekit/test_io.py:146asserts the dead method and locks in the misspelling, and the same test goes on at:147-149to calltruncate(None),truncate(6)andtruncate(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 theioprotocol'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, producingException 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()thendelplusgc.collect(); never closing; closing twice;buffer_save=True; a realopen(..., 'rb')file as the raw stream; andstream_closing=False. All produced empty stderr and exit code 0, including under-W always::ResourceWarning.The
stream_closing=Falsevariant does produce a genuine state split, and is still silent:close()atpcapkit/corekit/io.py:147-167indeed never callssuper().close(), so the gap is real. It stays invisible becauseIOBase.__del__readsself.closedthrough 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 insideflush()orself._stream.close()duringclose()itself, which cannot be arranged without editing the library.Notes
no_eofa way to stop, so extract() returns (#620) #639); re-verified here from scratch on375e9d411before filing.pcapkit/corekit/io.pyonmaindoes not carry itstruncatefix. Thetruncatebehaviour at issue here is whether it raises, not what it returns or pads, so fix(corekit): keep SeekableReader's buffer content when truncating it (#622) #633 does not bear on the question — but a reader should not assume its changes are present.truncate's content behaviour on this same method, and fix(corekit): keep SeekableReader's buffer content when truncating it (#622) #633 is its fix. This issue is about whethertruncateshould be callable at all, which is deliberately kept separate from how it behaves when it is.