Repository navigation
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
Activity
- addedquestionIssues asking how something works rather than reporting a defectIssues asking how something works rather than reporting a defect
on Sep 22, 2026 Answering rather than acting on this, since it is your call. Line numbers below are
mainat0c7f2b7c9; #663 shifts them but touches none of these methods.The contract, fetched rather than recalled
_pyio.pyon this machine (CPython 3.14.7,.../lib/python3.14/_pyio.py),IOBase.writableat 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
_checkWritableat 458-463, and the base every buffered reader in_pyioinherits —_BufferedIOMixin.truncateat 791-804:def truncate(self, pos=None): self._checkClosed() self._checkWritable() ... return self.raw.truncate(pos)
_pyio.BufferedReaderoverrides neither, so it inherits both. https://docs.python.org/3/library/io.html agrees:Return
Trueif the stream supports writing. IfFalse,write()andtruncate()will raiseOSError.and its ABC table lists
truncate,writableandseekableonly asIOBasestub/mixin methods —RawIOBase/BufferedIOBase/TextIOBasedo not restate them. Sotruncateis anIOBase-level contract point gated by bothwritable()andseekable(), which is why theseekablehalf of your quoted docstring is satisfied while thewritablehalf 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__ NoneCPython'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) -> TrueSame on
_pyio.BufferedReaderover 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_pyiohonoured it.Where that leaves
SeekableReadertruncate(pcapkit/corekit/io.py:317-378) has no writability check;write(:509) andwritelines(:385) both raiseUnsupportedOperation('write'), andpcapkit.utilities.exceptions.UnsupportedOperation(pcapkit/utilities/exceptions.py:477) really is anOSErrorsubclass. So two of the three writability-gated methods already honour the contract andtruncateis the outlier — this is not a class that is uniformly lax._pyio.BufferedReaderitself overrides neither — thetruncateat_pyio.py:791belongs to_BufferedIOMixin(declared at:765), and the overrides at:1239/:1270areBufferedWriter's — so a read-only buffered reader inherits both the check and the refusal. AndSeekableReader.truncatehas no production callers:grep -rn '\.truncate(' --include='*.py' pcapkit/returns nothing at all, and repo-wide every hit is intests/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/writelinesalready use. It matches the documented contract, it matches what realBufferedReaderobjects 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.truncatehere 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:
truncateis the method whose content behaviour was just fixed, and making it raise would make those tests unreachable rather than wrong.The
writeablemisspelling is a different kind of thingThat 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 -> TrueSo the method defined at
:380overrides nothing, and anything checking writability the standard way —io,shutil, a third-party caller — reads the inheritedIOBase.writableand never sees it. Both returnFalse, so there is no symptom; that is coincidence rather than correctness, and it means editingwriteablewould silently have no effect if the base class or the write-ability ever changed.tests/corekit/test_io.py:146asserts the dead method, so the misspelling is locked in by the suite.Worth spelling it the way the protocol spells it whichever way the
truncatequestion goes — but since renaming it is the natural moment to decide whetherwritable()should stayFalse, I have left both halves alone in #663 rather than pre-empting you. Leaving this open.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.pyon this machine (CPython 3.14.7,.../lib/python3.14/_pyio.py) —IOBase.writableat 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
_checkWritableat 458-463 and_BufferedIOMixin.truncateat 791-804, which is the base every buffered reader in_pyioinherits.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, heada38fd2cb1). 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 onmainat0c7f2b7c9; #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 atio.py:212,:375,:405and:492.- addedbugIssues reporting a defect (set by the bug report template; a default, not an assessment)Issues reporting a defect (set by the bug report template; a default, not an assessment)
on Sep 23, 2026
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsDone
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.