fix(corekit): refuse truncate on a reader that reports itself unwritable (#645) - #680
Conversation
``SeekableReader.truncate()`` now raises instead of returning a size, and the misspelled ``writeable()`` is spelled ``writable()``. Filed as **Changed** rather than **Fixed**, following the #617 entry: both halves are breaks to a public API, even though nothing inside the package called either method. CHANGELOG.md regenerated with ``util/changelog_md.py``; ``--check`` exits 0.
Supplementary measurement: does the Python-level
|
…ble (#645) **Behaviour change to a public API, twice over.** ``SeekableReader.truncate()`` now raises where it returned a size, and the misspelled ``writeable()`` is gone in favour of ``writable()``. * ``io.IOBase.writable`` gates two methods -- "If False, write() and truncate() will raise OSError" -- and ``writable()`` here returns False. ``write`` and ``writelines`` already honoured that; ``truncate`` returned its new size, so a caller that checked ``writable()`` first -- which is what the contract invites -- got a surprise either way round. It now raises ``UnsupportedOperation('truncate')``: the same refusal ``write`` uses, and the same message CPython's own C ``BufferedReader`` carries. * The resizing is not deleted, only made private, as ``_truncate_buffer``. It never touched the underlying stream -- it resizes a private lookback window -- and it is the only route to the "position dropped by the window" state that ``seek`` and the four buffered read paths must refuse, which #643 and #644 landed tests for. Deleting it would take those tests' mechanism with it. * ``writeable()`` was not an override of anything: ``'writable' in SeekableReader.__dict__`` was False and ``SeekableReader.writable is io.IOBase.writable`` was True, so ``io``, ``shutil`` and any third-party caller read the inherited value and never saw the one defined here. Both returned False, which is why there was no symptom. Renamed to the spelling the protocol uses; the value it reports is unchanged, and was always honest. * ``docs/source/pcapkit/corekit/io.rst`` follows the rename. The exception is ``pcapkit.utilities.exceptions.UnsupportedOperation``, which subclasses ``io.UnsupportedOperation`` and so is an ``OSError``. The in-library exception *is* the stdlib one here, so the house rule and the ABC agree rather than having to be traded off. tests/corekit/test_io.py: 31 -> 34 tests and 35 -> 41 subtests, module coverage 100% before and after (235 -> 237 statements, 0 missed either side). The three new tests fail on b34f132 with exit 1 -- 6 SUBFAILED and 1 FAILED -- and pass here with exit 0. tests/corekit/: 179 passed, 413 subtests, exit 0. Fixes #645.
83e06ec to
c7e42c2
Compare
Cross-review: GOOD TO GOIndependent cross-review by a subagent on a different model (Sonnet), briefed to falsify rather than to bless — a verdict per claim, with evidence it obtained itself, and disagreements treated as the valuable output. No model substitution was needed; Sonnet was the intended model and the one that ran. It reviewed
It reproduced the base-versus-head failing/passing runs byte-for-byte, and read its exit codes from a file written by What it added that the PR did not haveIt measured the version boundary on five interpreters, where this PR only had one. On CPython 3.10.21, 3.11.15, 3.12.14, 3.13.15 and 3.14.7 it verified that It also independently confirmed the What it disputed, and what I did about eachThree findings. One is now fixed; two are declined, with reasons.
The sharper finding on claim 7, recorded honestlyThe reviewer confirmed But it sharpened the claim in a way worth stating plainly: nothing inside Also measured since the body was written
Note also that the task notification for that failing run reported "exit code 0" while the run's own rc file held 1 — the wrapper-versus-pytest exit-code discrepancy, hit live. Every exit code in this PR is read from a file for that reason. |
Fixes #645. Owner decision on that issue, in one word: "fix it" — the API break is accepted.
What was wrong
io.IOBase.writablegates two methods, and says so in the one place both implementations agree:That is
_pyio.py:451-456on CPython 3.14.7 (/home/linuxbrew/.linuxbrew/opt/python@3.14/lib/python3.14/_pyio.pyon the machine this was measured on — line numbers re-read here, not taken from the issue), with_checkWritableat458-463and_BufferedIOMixin.truncate— the base every buffered reader in_pyioinherits, and neitherwritablenortruncateis overridden by_pyio.BufferedReader— at791-804.SeekableReaderhonoured that gate for two of the three methods it covers and not for the third:So a caller that checked
writable()before callingtruncate— which is exactly what the contract invites — got a surprise either way round. And this is not a pure-Python nicety the accelerated path skips: every ordinary read-only file object in CPython raises here, measured rather than recalled.Separately, and named in the same issue: the writability override in this file was spelled
writeable, which is not how theioprotocol spells it, so it overrode nothing.io,shutiland any third-party caller therefore read the inheritedIOBase.writableand never saw the method defined here. Both returnedFalse, which is the coincidence that hid it — there was no symptom, and editingwriteablewould silently have had no effect.Was
writable()honest?Yes, and this is worth stating because the two halves of the issue could have disagreed and did not.
writable()resolved toIOBase.writableand returnedFalse; the reader genuinely cannot write, sincewriteraises. So the reporting was never the dishonest half — the missing guard ontruncatewas, and the misspelling was about where the method was defined, not about what it answered. The value this PR reports is unchanged. Hadwritable()been returningTrueover an unwritable stream, the guard and the reporting would both have needed changing; that is not the case here.What changed
truncate()raisesUnsupportedOperation('truncate'), unconditionally. Unconditional rather thanif not self.writable()becausewriteandwritelinesare unconditional too — this reader is never writable, and a guard whose false branch cannot execute is a dead branch with no coverage._truncate_buffer. It never touched the underlying stream — it resizes a private lookback window, which is precisely the counter-argument the issue raised — and it is the only route to the "position dropped by the window" state thatseekand the four buffered read paths must refuse. SeekableReader's position bookkeeping is not maintained across operations: a refused seek leaves _tell moved, SEEK_CUR/SEEK_END can drive it negative, and peek desyncs the buffer cursor #643 and The four buffered read paths cap size with min(size, self._buffer_cur - 1): a count-less-one measured from the buffer's start instead of the read position #644 landed tests for those refusals; deleting the mechanism would take their mechanism with them and make_seek_buffer's documentedSeekErrorbranch unreachable. It has no production callers, and had none as a public method either (grep -rn '\.truncate(' pcapkit/→ nothing), so this narrows exposure rather than removing capability.writeable()→writable(). Same body, sameFalse, the spelling the protocol uses. Not kept as an alias: an alias would keep a name that answers nothing in the public API forever, and the issue's point is that the suite had locked the misspelling in.docs/source/pcapkit/corekit/io.rstfollows the rename._truncate_bufferis deliberately not added there, matching_write_bufferand_seek_buffer, which are also undocumented.The exception type, argued rather than assumed
The contract names
OSError, a builtin, and the house rule is in-library exceptions for in-library errors. Here there is no trade-off to make, because the two coincide by inheritance:pcapkit'sUnsupportedOperation(pcapkit/utilities/exceptions.py:477) subclassesio.UnsupportedOperation, which subclassesOSError. So raising the in-library exception satisfies the stdlib contract literally —isinstance(exc, OSError)isTrue, andisinstance(exc, io.UnsupportedOperation)isTrue, which is what makes the ABC-equivalence assertion below pass. Raising a bareOSErrorinstead would have been strictly weaker (it losesBaseError's behaviour and the narrower type) for no gain in conformance. It is also whatwriteandwritelinesin this same class already raise, and'truncate'is the exact message CPython's CBufferedReadercarries.Assertion against the ABC, not just the behaviour
test_truncate_refuses_with_the_same_exception_the_stdlib_reader_raisesdoes not name the expected type. It captures it by running the same call on a real read-only file object, then assertsSeekableReaderraises that captured type:Both implementations are baselined — the accelerated
io.BufferedReaderand_pyio.BufferedReaderover a non-writable raw stream. Their messages differ (truncateversusFile or stream is not writable.) and their type does not, which is why the type is what is asserted. Capturing rather than naming it is also what keeps the assertion valid across the 3.10–3.15 matrix: theassertRaises(OSError)around the baseline call is what enforces the one assumption being made, so a CPython version that raised something outside theOSErrorlattice would fail on the stdlib call with a self-describing message rather than mysteriously on ours.Evidence
Exit codes read from files, never from a pipeline. Tree asserted before every measurement —
pcapkit.__file__=/local/home/jarryx/GitHub/PyPCAPKit/.claude/worktrees/agent-a728a1132f06428d8/pcapkit/__init__.py, with the editable-install finder stripped bygetattr(f, '__module__', '')(the finders atsys.meta_path[1:5]are classes, so a filter ontype(f).__name__strips nothing).The three new tests on
b34f132f6, this PR's base —pcapkit/corekit/io.pyrestored toHEADand the new tests run against it:exit code 1. The failures, in full:
AssertionError: UnsupportedOperation not raisedfor the first six;AssertionError: 'writable' not found in mappingproxy({...'truncate': ..., 'writeable': ...})for the last, whose dump is itself the evidence thatwriteablewas there andwritablewas not. Note the "2 passed" on the same line as the failures: those two per-test lines reportPASSEDwhile every subtest under themSUBFAILED, so the exit code and the subtest tally are the only trustworthy signal.The same three tests here:
exit code 0. Six subtests passed where six
SUBFAILED.Coverage did not go backwards.
coverage run -m pytest tests/corekit/test_io.py,--include='pcapkit/corekit/io.py', branch coverage on:b34f132f6Both exit 0. The changed lines already executed on the base —
writable'sreturn Falseand every line of the resize mechanism — so per the standing rule the subtest count is quoted instead: 35 → 41, and 31 → 34 tests.Scope run:
tests/corekit/—179 passed, 4 warnings, 413 subtests passed in 158.48s, exit code 0. The full suite was deliberately not run (it has peaked at 41.4 GB RSS on this host).EXPECTED_FAILURESintests/protocols/test_option_roundtrip_unit.py— imported rather than grepped, since**unpacking hides the entries from a grep. 45 entries, unchanged: nothing in that path callstruncate,writeableorwritable, confirmed bygrep -rn '\.truncate(\|\.writeable(\|\.writable(' pcapkit/, which returns nothing outsideio.py's own definitions.Version sensitivity:
_pyiois insys.stdlib_module_namesand present on every CPython in the matrix;ast.parse(..., feature_version=)accepts both changed files at 3.10, 3.11, 3.12 and 3.13, so nothing here needs a floor above the current one. Only 3.14.7 was available locally to run, which is why the ABC baseline is captured at runtime rather than hardcoded — that is the part a single interpreter could not otherwise vouch for.Labels
fix+breaking.fixbecause this corrects a documented-contract violation rather than adding a capability; the subject prefix isfix:, which is what the type labels key on.breakingbecause this raises where it used to succeed, in two separate ways:truncate()now raisesUnsupportedOperationinstead of returning a size, andwriteable()is gone, so an external caller of either gets an exception where they used to get a value. Nothing inside the repository breaks —truncatehad no production callers and neither spelling of the writability method was called anywhere inpcapkit/— so the break is entirely about external callers, which is exactly the risk the issue was raised to get a decision on.docsdoes not apply (it is scoped to documentation-only changes) and neither doestest(the subject prefix isfix:).Not claimed
CI is not claimed green — the queue is deep, and a check count of "1 pass" on this repository is
pyup.io/safety-ci, a StatusContext rather than an Actions job. The changelog bullet for this is not on this branch; it goes to #657 (docs/changelog-1.5.0) separately, per the standing rule that no changelog file is touched on a feature branch.