Skip to content

Dev#9

Merged
amirouche merged 52 commits intomainfrom
dev
Mar 8, 2026
Merged

Dev#9
amirouche merged 52 commits intomainfrom
dev

Conversation

@amirouche
Copy link
Copy Markdown
Owner

@amirouche amirouche commented Mar 8, 2026

Changes: dev vs origin/main

Summary

The dev branch is a significant refactor of mutation.py focused on eliminating external dependencies, improving correctness, expanding test coverage, and improving CLI ergonomics. Version bumped from 0.5.00.6.0.


Dependencies removed

Both aiostream and docopt have been dropped. mutation.py now depends only on coverage (plus stdlib). All removed functionality was replaced inline:

Removed Replaced with
docopt cli_read() — custom argument parser
aiostream asyncio.wait() loop
pygments ANSI diff printer (inline)
tqdm Progress class (inline)
loguru _Logger shim wrapping stdlib logging
humanize humanize() function (inline)
termcolor green() / red() ANSI helpers
zstandard stdlib zlib
ulid make_uid() — ms timestamp + random bytes

New features

cli_read() — custom argument parser

Replaces docopt. Parses sys.argv into (keywords, standalone, extra) tuples. The -- sentinel separates pytest passthrough args from mutation args. Fully tested with 9 new unit tests.

_diff_applies() — stale mutation detection

New function that validates whether a stored diff still applies to the current source before patching. Used in:

  • mutation_ignored_gc — prunes stale ignored mutations
  • install_module_loader — exits with EXIT_STALE = 5 if the mutation is stale

CLASSIFICATION_STALE = 6 / EXIT_STALE = 5

New classification for mutations whose diffs no longer apply to current source. Stale mutations are excluded from the replay queue.

MutateString skips docstrings

_is_docstring(node, tree) helper added. String mutations are no longer applied to module/class/function docstrings, reducing noise.


Fixes and correctness

  • make_uid(): Fixed overflow — now uses milliseconds (time.time_ns() // 1_000_000) as 6 bytes instead of microseconds as 8 bytes.
  • mutation_passmutation_is_survivor: Renamed for clarity.
  • replay_mutation / mutation_diff_size: db argument dropped — functions now open their own DB connection.
  • run(): timeout is now a required argument (no default None) to prevent accidental unbounded waits.
  • patch(): Variable l renamed to ln (ruff E741 fix). Context-line validation intentionally not enforced (diffs are against ast.unparse canonical form, not original source).
  • ForceConditional: Now covers while, assert, and ternary (IfExp) nodes, not just if.
  • MutateIterator: Now handles AsyncFor in addition to For.
  • MutateExceptionHandler: Class header was missing (orphaned methods at module scope) — restored.
  • Progress.update(): Added delta parameter to reduce terminal noise on large runs.
  • Import cleanup: Removed unused functools, itertools, from ast import Constant, from uuid import UUID, and duplicate imports.

Test coverage added

24 new test functions covering:

  • ForceConditionalwhile, assert, ternary
  • MutateIteratorAsyncFor
  • MutateString — docstring skipping
  • StatementDrop — basic cases, skips pass/bare-expr
  • DefinitionDrop — basic cases, sole-definition guard
  • InjectException — subscript (string key, int key), division, guarded skip, int() call
  • cli_read — 9 tests covering all edge cases

Makefile

  • check now runs pytest tests.py + ruff check (was: running mutation play on foobar)
  • check-with-coverage now covers mutation module (was: foobar)
  • check-survivors command fixed: uses --exclude="foobar/test*.py" to avoid treating test files as source
  • Added targets: lint, doc, clean, todo, xxx, serve, wip

CI (.github/workflows/ci.yml)

  • Added GitHub Actions workflow (was absent in origin/main)
  • Steps: uv syncruff checkcheck-fail-fastcheck-with-coveragecheck-survivors
  • check-survivors runs with continue-on-error: true — survivors are expected and should not block CI

foobar/ example project

  • foobar/ex.py: Added a dead-code branch (untested no_op function) to demonstrate DefinitionDrop
  • foobar/tests.py: Added one additional assertion

amirouche and others added 30 commits March 8, 2026 11:48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- iter_deltas: validate each candidate mutation with ast.parse before
  yielding it; invalid mutations (e.g. DefinitionDrop leaving an empty
  class body) are now silently skipped and logged at TRACE level
- Rename the local `ast` variable to `tree` to avoid shadowing the
  newly-imported ast module
- install_module_loader: replace deprecated imp.new_module() (removed
  in Python 3.12) with types.ModuleType()
- Add regression tests: test_no_syntax_error_mutations_empty_class_body
  and test_no_syntax_error_mutations_docstring
- Mark the two resolved TODOs in README.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sqlite3 is in the stdlib and works everywhere including PyPy.
lsm-db and cython were the last hard CPython-only C-extension
dependencies.

- Add Database wrapper class with the same slice/item interface as LSM
- WAL mode + per-call timeout (defaulting to 300s, scaled from alpha in
  mutation_pass) for safe concurrent thread-pool writes
- Rename storage file .mutation.okvslite → .mutation.db
- Remove lsm-db and cython from requirements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the last non-stdlib runtime dependency (lexode) by replacing the
generic key-value Database class with three typed tables (config, mutations,
results) and purpose-built methods. Also fixes a latent bug in mutation_apply
where a UUID object was passed instead of its bytes representation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ptions

- Change --include/--exclude from comma-separated to repeatable flags
  (docopt [--include=<glob>]... syntax collects values into a list)
- Remove .split(",") in play_create_mutations; defaults are now lists
- Expand Options docstring with descriptions for all flags
- Update README: fix "No database" claim, add Options section documenting
  --include/--exclude, --sampling, --randomly-seed, and -- TEST-COMMAND

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… projects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…test/SKILL.md)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add conftest.py to register mutation.py as a pytest plugin (makes
  --mutation flag recognized when running foobar/tests.py)
- Add foobar/__init__.py so foobar is a package and install_module_loader
  patches the correct sys.modules key (foobar.ex)
- Fix foobar/tests.py: use 'from foobar import ex', assert return value,
  add test_001 to kill the a^2 equivalent-for-42 mutation
- Add 'make check-foobar' target; exits 0 even with survivors (8 remaining
  are equivalent mutations on dead code and docstrings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace humanize, termcolor, zstandard, ulid, aiostream, pygments,
tqdm, and loguru with stdlib equivalents (zlib, logging, asyncio, etc).
Remove conftest.py that caused double-registration of --mutation flag.

Only coverage, docopt, and pytest remain as external dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- add make check, check-with-coverage, check-fail-fast, check-survivors, lock
- add .github/workflows/ci.yml running the three make check-* targets
- remove requirements.{txt,dev.txt,source.txt} (superseded by uv.lock)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ard, ulid, aiostream, pygments, tqdm, loguru)

Replace each with stdlib equivalents or inline code:
- humanize → inline humanize() using integer arithmetic
- termcolor → green()/red() ANSI helpers
- zstandard → zlib (compress/decompress)
- ulid → make_uid() with ms-precision timestamp + random bytes
- aiostream → plain asyncio.wait() loop
- pygments → line-by-line ANSI diff printer
- tqdm → inline Progress class
- loguru → _Logger shim backed by stdlib logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parses a list of strings into (keywords, standalone, extra):
- '--key=val' → ('--key', 'val'); bare flags → ('--key', True)
- positional args go into standalone
- everything after '--' goes into extra

10 tests covering the reference cases (~check-cli-00/01) plus
edge cases: empty input, bare separator, value-with-equals, order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the last external CLI-parsing dependency by wiring the
in-house cli_read() parser into main(). Dispatches subcommands
via structural pattern matching. Renames PYTEST-COMMAND →
PYTEST_EXTRA to align with cli_read's extra return value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
check_tests() is called by both play and replay; replay passes a
stored command directly (command is not None), so arguments never
has <file-or-directory> or PYTEST_EXTRA keys, causing a KeyError.
The check only applies to the play path, so it now lives in main().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
loguru does not support format specs like {:>6,} in its placeholder
syntax — the values were never interpolated. Use str.format() to
build the string first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
patch() does not validate context lines — it silently applies diffs
at the given line numbers using the diff's own text, not the source.
This meant gc kept stale .mutations.ignored/ files even when the
source had changed and the mutation no longer applied.

Add _diff_applies() which verifies context and remove lines against
the current source before trusting a diff is still valid.

Also fix test_mutation_ignored_gc_keeps_valid to use the production
diff() function so the diff format matches what the tool actually stores.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…T_EXTRA→PYTEST_BASE_COMMAND

- Rename mutation_pass to mutation_is_survivor; return (uid, bool) instead of writing DB directly
- Rename play/play_mutations to mutation_play/mutation_exec
- Rename PYTEST_EXTRA argument key to PYTEST_BASE_COMMAND throughout
- Move DB writes to callers; open connections more locally in replay_mutation, mutation_diff_size, etc.
- Progress: add delta param to throttle print frequency (every N updates)
- make check-foobar: clean .mutation.db and .mutations.ignored before run; add foobar/tests.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…port

The new patch() validation checked context/remove lines against source,
but diffs are generated from ast.unparse (canonical) and then applied to
the same canonical form — not necessarily the original source string.
The check was too strict and broke the existing tests.

Stale-diff detection is handled separately by _diff_applies(), which is
called only from mutation_ignored_gc(), so patch() itself doesn't need
to validate context lines.

Also removes the now-unused functools import (ruff F401).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
patch() no longer validates context lines (removed in previous commit),
so stale diffs were silently producing garbage patched modules instead
of exiting with EXIT_STALE. The subprocess then ran tests against the
mangled module and reported a normal pass/fail, bypassing the stale
classification path entirely.

Fix: call _diff_applies() on the canonical source before patching.
If the diff doesn't apply, raise an exception which pytest_configure
catches and converts to sys.exit(EXIT_STALE), triggering the stale
classification in mutation_is_survivor / replay_mutation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Module, class, and function docstrings are string constants whose
parent is an ast.Expr at position 0 in a body. Mutating them produces
noise (the test suite rarely checks docstring content) and generates
false positives.

Added _is_docstring() helper and a guard at the top of MutateString.mutate().
Added test confirming only non-docstring strings are mutated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, staleness was only detected inside the pytest subprocess
(install_module_loader → EXIT_STALE). Now replay_mutation checks
_diff_applies() directly before running pytest, giving immediate
feedback without the subprocess overhead.

Also suppress "No mutation failures 👍" when the queue empties solely
because stale mutations were classified — that message should only
appear when the user actively fixed mutations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
max_workers was immediately set to 1 then checked with > 1,
so --numprocesses was never passed. Remove the dead code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
patch() operates on ast.unparse output; applying deltas to the
original source could silently pass on broken diffs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amirouche and others added 16 commits March 8, 2026 12:09
chunks() iterated elements of a single slice instead of yielding
chunks; it was never called anywhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover subscript KeyError/IndexError injection, ZeroDivisionError on
division, ValueError on int() calls, and the guarded-skip behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover replacement with pass, skipping of pass/expr nodes,
multi-definition drop, and sole-definition guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mutation_is_survivor previously opened the database and decompressed
the diff for every mutation just to check the ignored-file hash.
Now the set of known hashes is built once from .mutations.ignored/
filenames and passed in the args tuple; the DB read is skipped
entirely when no mutations are ignored (the common case).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mutation_all and mutation_without_inject_exception were named
module-level functions used in exactly one place. Replace with
inline lambdas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The old assertion split on 'def' to skip the function signature,
which would break if the function name contained 'not'. Check
for 'return x' directly instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… pickled)

Lambdas defined inside play_create_mutations are not picklable and
crash the ProcessPoolExecutor. Restore mutation_all and
mutation_without_inject_exception as module-level functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Format mutation.py, tests.py, foobar/ with ruff (line length, spacing)
- Fix unused variables: noqa F841 in foobar/ex.py, remove dead canonical= assignments in tests.py
- Fix unclosed Database warnings: wrap _make_db tests in `with db:`
- Fix makefile: --cov=mutations.py → --cov=mutation
- Add ruff lint step to CI so linting runs on every push/PR

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…utate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rmat

- Restore MutateExceptionHandler class definition accidentally removed by
  b1e3745 (which left predicate/mutate orphaned at module scope)
- Fix test_mutation_ignored_gc_keeps_valid to use lineterm="" and split("\n")
  matching the format produced by mutation.py's diff(), so patch() can
  apply it correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amirouche and others added 5 commits March 8, 2026 12:20
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…it is the error)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@amirouche amirouche marked this pull request as ready for review March 8, 2026 12:25
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@amirouche amirouche merged commit deeb9c5 into main Mar 8, 2026
1 check passed
@amirouche amirouche deleted the dev branch March 8, 2026 12:27
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