Skip to content

refactor: route writer-handle patching in tests through one helper#127

Merged
michael-denyer merged 1 commit into
masterfrom
chore/pyrefly-writer-tests
Jul 26, 2026
Merged

refactor: route writer-handle patching in tests through one helper#127
michael-denyer merged 1 commit into
masterfrom
chore/pyrefly-writer-tests

Conversation

@michael-denyer

Copy link
Copy Markdown
Owner

Clears the largest remaining pyrefly cluster. 100 → 77 entries.

All 23 entries in tests/test_incremental_writer.py were one root cause, not 23
problems. IncrementalAssocWriter._file is None until __enter__ runs, so every test that
drives the retry and rollback paths by replacing a method on the live handle indexes into an
optional:

original_write = writer._file.write      # error
writer._file.write = flaky_write         # error

Adds open_handle(writer), which asserts the writer is open once and returns the narrowed
handle, and rewrites the 23 sites through it.

Two details that shaped the fix:

  • Typed as the concrete io.TextIOWrapper, not typing.TextIO. TextIO declares
    __slots__, so assigning to .write on it is itself an error — that annotation would have
    traded 23 entries for 14. Found by trying it.
  • failing_seek grew the real seek(cookie, whence=0) signature; the one-parameter stub was
    not assignable to TextIOWrapper.seek.

Verification

Test-only, and a pure expression rewrite — but "the tests still pass" is a weak claim for a
change that edits the very lines doing the fault injection, so it was checked directly.

The helper returns the same object the tests patched before:

open_handle(w) is w._file:        True
patched write actually invoked:   True
patch visible via w._file:        True

The suite still bites — mutating _write_buf and re-running:

Mutation Result
Drop the retry loop (single attempt) 4 failed
Drop the self._count += count increment 8 failed
Check Result
pytest tests/test_incremental_writer.py 29 passed
pytest tests/ 2242 passed, 10 skipped, 3 xfailed — unchanged
ruff check / ruff format --check clean
pyrefly check --baseline on 3.11.13/numpy 2.4.6 and 3.13.5/numpy 2.5.1 0 errors both

A gap that sweep exposed — not fixed here

Deleting the rollback handle.seek(pos) or handle.truncate() from _write_buf fails
nothing. The rollback's failure is covered
(test_write_buf_deletes_partial_on_non_retryable_rollback_failure); its success — that a
failed write is actually truncated back and the file left consistent — is not.

Left alone deliberately: closing it is a test-coverage change, not a burn-down, and it wants
its own PR.

Running total

174 (#121) → 152 (#122) → 135 (#123) → 113 (#124) → 107 (#125) → 100 (#126) → 77.

A 56% reduction. Largest remaining clusters are all small now: tests/test_jlinalg_dgemm.py
(8), tests/test_kinship_io.py (7), tests/test_runner_numpy.py (5),
scripts/bench_all_backends.py (4), src/jamma/lmm/runner_numpy_streaming.py (4).
src/ holds 18 of the 77.

Clears the largest remaining pyrefly cluster: all 23 entries in
tests/test_incremental_writer.py (100 -> 77). They were one root cause, not
23 problems. `IncrementalAssocWriter._file` is None until `__enter__` runs, so
every test that drives the retry and rollback paths by replacing a method on
the live handle -- `writer._file.write = flaky_write` and friends -- indexed
into an optional.

Adds `open_handle(writer)`, which asserts the writer is open once and returns
the narrowed handle, and rewrites the 23 sites to use it. Typed as the concrete
`io.TextIOWrapper` rather than `typing.TextIO`: the latter declares `__slots__`,
so assigning to `.write` on it is itself an error, which would have traded 23
entries for 14.

`failing_seek` also grew the real `seek(cookie, whence=0)` signature; the
one-parameter stub was not assignable.

Test-only, and a pure expression rewrite: `open_handle(writer)` returns the
identical object the tests patched before, verified by identity, and a patch
applied through it is still visible as `writer._file.write` and still runs on
the write path. 2242 passed, 10 skipped, 3 xfailed -- unchanged.

Checked that the suite still bites rather than passing vacuously, by mutating
`_write_buf`: dropping the retry loop fails 4 tests, dropping the `_count`
increment fails 8.

That sweep did surface a real pre-existing gap, left alone here because closing
it is a test-coverage change rather than a burn-down: deleting the rollback
`handle.seek(pos)` or `handle.truncate()` fails nothing. The rollback's
*failure* is covered
(test_write_buf_deletes_partial_on_non_retryable_rollback_failure); its success
-- that a failed write is actually truncated back and the file left consistent
-- is not.

Baseline regenerated on Python 3.11 / numpy 2.4.6 per #121; `pyrefly check
--baseline` reports 0 errors on both 3.11.13/2.4.6 and 3.13.5/2.5.1.
@michael-denyer
michael-denyer merged commit 5dd3878 into master Jul 26, 2026
12 checks passed
@michael-denyer
michael-denyer deleted the chore/pyrefly-writer-tests branch July 26, 2026 23:51
michael-denyer added a commit that referenced this pull request Jul 26, 2026
Closes the gap #127 found by mutation testing: deleting `handle.seek(pos)` or
`handle.truncate()` from `_write_buf` failed no test. The rollback's *failure*
was covered by
test_write_buf_deletes_partial_on_non_retryable_rollback_failure; its success
-- that a failed attempt is discarded and the retry writes over clean state --
was not, so the two lines that guarantee a consistent output file were free to
disappear.

The new test makes the first attempt put more bytes into the stream than the
retry will, then fail with a retryable errno, and asserts on the resulting file
rather than on the call sequence. Both halves of the rollback are load-bearing
under that setup:

- Without `seek(pos)`, `truncate()` cuts at the position left after the debris,
  so the retried line lands after it.
- Without `truncate()`, `seek(pos)` rewinds but the retried line is shorter
  than the debris, so the debris tail survives past the end of the line.

Either way the writer reports success over a malformed file, which is why the
assertion is on content.

Confirmed to bite: with `handle.truncate()` stubbed out, 1 failed; with
`handle.seek(pos)` stubbed out, 1 failed; with both, 1 failed. Each fails on
this test's own assertion ("Rollback must discard the failed attempt's bytes"),
not incidentally elsewhere. Before this test all three mutants survived.

Test-only. `pytest tests/` -- 2243 passed, 10 skipped, 3 xfailed. Baseline
unchanged at 77 entries; this adds none.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant