Skip to content

Keep Python's evaluation order, show and/or operands, harden the hooks - #4

Merged
RonnyPfannschmidt merged 6 commits into
mainfrom
rewrite-order-and-hook
Oct 7, 2026
Merged

RonnyPfannschmidt merged 6 commits into
mainfrom
rewrite-order-and-hook

Conversation

@RonnyPfannschmidt

Copy link
Copy Markdown
Contributor

Written by Claude Opus 5.5 via Claude Code for the cot.assert maintainers; I prompted it, it did the work, I read it.

This checks cot.assert against pytest's rewrite coverage matrix, vendored from the series ending in pytest-dev/pytest#14916, and against a new hypothesis fuzzer. The fuzzer runs generated asserts plain and rewritten and compares outcome, side-effect order and final bindings.

Behaviour fixes

  • A name is read before later operands that can run code. Previously assert value == f(value := 3) passed where plain Python fails, and names rebound through global/nonlocal compared wrong.
  • A method is looked up, and its receiver read, before its arguments run. obj.take(f(obj := None)) raised AttributeError. The bound method sits in a temporary that is only called, never passed to v().
  • assert (a or b) and c raised UnboundLocalError when a was true and c false. Found by the fuzzer; it predates this PR.

Explanations

Hooks

testing/test_rewrite_coverage.py is MIT-licensed pytest code. It carries pytest's licence notice and is excluded from ruff so that a resync stays a copy.

Checked locally:

RonnyPfannschmidt and others added 6 commits September 25, 2026 14:11
Checked against pytest's coverage matrix for its rewriter, vendored as
testing/test_rewrite_coverage.py from the series ending in
pytest-dev/pytest#14916. It failed 20 cases; three were wrong behaviour.

A name was left in place and read only when the expression around it was
assembled, after later operands had run: a walrus operator, or a call
rebinding it through global or nonlocal, changed what it compared as, and
`assert value == f(value := 3)` passed where plain Python fails. It is
now read into a temporary first whenever a later operand can run code.
A method was looked up after its arguments, and its receiver read after
them; the bound method now goes into a temporary when an argument can run
code, only ever called, never passed to v(), so RPython has nothing to
render.

`and`/`or` as a value showed only its result. Its later operands run on
some runs only, so their values cannot go into slots every failure passes
(they would read temporaries never assigned, and cannot be preset to None:
RPython keeps a temporary at one type). They are appended to a list as
they are evaluated, which fail() adds to the labels and values. The same
problem existed at statement level: `assert (a or b) and c` reported the
or's operands on the failure after it and raised UnboundLocalError; an or
nests its operands only where nothing is tested after it now.

Subscripts show container and key and conditional expressions their
condition, as pytest-dev/pytest#14815 and #14816 do, and a comparison or
and/or compared with something is parenthesized as pytest does.

The flow cases translate each new shape through the llinterpreter and C.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
Generated asserts run compiled as they are and rewritten, and must agree
on the outcome, on every side effect in order, and on the names left
bound; a rewritten failure must also render. The grammar mixes what makes
evaluation order observable: walrus, traced calls, a traced method lookup,
globals and nonlocals rebound by calls, operands that raise, and every
kind of short-circuit.

Against the previous commit's parent it finds the method lookup order
within seconds; it also found the statement-level or fixed there. Both
are kept as fixed cases. 80000 examples over eight seeds run clean.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
Under lazy imports (PEP 810) a finder that imports anything, even sys or
fnmatch, asks itself about the module it is importing: the standalone
hook failed with ImportCycleError on 3.15 (pytest-dev/pytest#14632).
Standard library, pytest and cot_assert names are ruled out first, from
a set taken at import, by string operations only; a wildcard or
python_files no longer selects them, an explicit name or a mark still
does. pytest marks cot-assert itself as a plugin, and rewriting it would
need itself; it is never selected.

pytest-dev/pytest#15022 recognises a rewritten module by its loader
being the hook, and asks the hook _should_rewrite: the pytest hook is
now the loader of what it rewrites, delegating to the standalone one,
and answers that. It also rewrites pkg/__init__.py named on the command
line, whose initial-path stem was __init__.

Checked on 3.15.0rc1 with PYTHON_LAZY_IMPORTS=all, standalone and with
pytest from pytest-dev/pytest#14844.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
The same licence as pytest, whose rewriter this mirrors and whose coverage matrix is now vendored here. All commits so far are by the one copyright holder.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
@RonnyPfannschmidt
RonnyPfannschmidt merged commit d1d8d1c into main Oct 7, 2026
10 checks passed
RonnyPfannschmidt added a commit that referenced this pull request Oct 7, 2026
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