typemut
Mutation testing for Python type annotations.
Standard mutation testing tools (cosmic-ray, mutmut) mutate runtime code and check if tests catch it. typemut mutates only type annotations and checks if type checkers (mypy, pyright) catch the change.
- Mutant killed = type checker reports an error (types are strict enough)
- Mutant survived = no type error (types are too loose or type checker coverage is weak)
Installation
pip install typemut
# or
uv add typemut
Quick Start
- Create
typemut.tomlin your project root:
[typemut]
module-path = "src/myproject"
test-command = "make typecheck" # must exit non-zero on type errors
checker = "mypy" # or pyright/basedpyright; needed when test-command is a wrapper like make
timeout = 30
[typemut.operators]
remove-union-member = true
remove-literal-member = true
widen-type = true
remove-optional = true
add-optional = true
widen-container-type = true
swap-iterator-generator = true
typevar-variance = true
- Run:
typemut run # full pipeline: discover + execute + report
typemut html --open # generate HTML report and open in browser
Or from another directory:
typemut -C /path/to/project run
- Run on several workers. Each mutant is a full type checker run, so a single
worker is slow on a real project.
--jobs Nruns mutants on N workers in parallel:
git status --porcelain # must print nothing: workers only see committed code
typemut run --jobs 8 # 8 type checker processes at once
With --jobs greater than 1, typemut creates N temporary git worktrees of the
current commit, splits the mutants between them (all mutants of one file go to
the same worker), runs each worker in its own process and removes the worktrees
at the end. What this means for your project:
- The working tree must be clean. Uncommitted or untracked files would not be in the worktrees, so typemut refuses to start and lists them. Commit or stash them first.
test-commandruns inside each worktree, not in your project directory. Keepmodule-pathand paths intest-commandrelative to the project root. Git-ignored files (.venv, generated code) are not copied: the type checker must be callable without them — e.g. installed in an activated virtualenv or onPATH.- The type checker's cache is copied into each worktree, so workers start
warm: the baseline check fills it in the project first, then every new
worktree gets a copy with modification times and symlinks kept (mypy drops
cache entries whose times differ). For mypy that is the directory shown as
cache:in theType checker:line (see Type checkers), copied only when it is configured as a relative path inside the project, under the configured name (a symlinked.mypy_cachelands in the worktree as.mypy_cache, where mypy looks for it). mypy in a worktree finds an absolute cache directory (MYPY_CACHE_DIR=/abs/path, an absolute--cache-dirorcache_dir, or one built from~or$VAR) at the same place as in the project, so all workers share that one cache instead (a warning says so); use a relative path inside the project to give each worker its own copy. A cache outside the project (../mypy-cache) is neither listed nor copied, and workers start cold;--cache-dir=/dev/nullturns mypy's cache off, so there is nothing to copy or warn about. pyright andgenerichave no cache to copy. A missing cache is skipped and a failed copy is logged as a warning; that worker then starts cold. If the copy is interrupted (Ctrl-C), the half-made worktree is removed. - Pick N by CPU cores and memory: every worker runs its own type checker process (mypy on a large project can take gigabytes of RAM). The number of CPU cores is a good start.
- The baseline check (type checker on unmodified code) runs once, in the project itself, before the workers start.
--jobs is also available on typemut exec. Without it (or with --jobs 1)
mutants run one by one directly in your project, which needs neither git nor a
clean tree.
Commands
Global option: -C, --project-dir PATH — change to this directory before running
(the target project's root, where typemut.toml lives).
| Command | Description |
|---|---|
typemut run |
Full pipeline: discover mutations, run the type checker on each, show the report |
typemut init |
Discover mutations and store them in SQLite (replaces previous results; the result cache keeps the kills of incremental runs) |
typemut exec |
Run the type checker against each pending mutation |
typemut report |
Show the terminal report |
typemut html |
Generate the HTML report with diffs |
init + exec + report are the steps of run. Running them separately lets
you resume: results are saved as they arrive, so after an interrupted exec
(Ctrl+C, CI timeout) run typemut exec again and it continues with the
mutants that are still pending.
typemut run
| Option | Default | Description |
|---|---|---|
--config PATH |
typemut.toml |
Config file |
--db PATH |
db from config (typemut.sqlite) |
Database file |
--jobs N |
1 |
Number of parallel workers (see Quick Start) |
--incremental / --no-incremental |
incremental from config (off) |
Reuse kills of earlier runs whose files are unchanged (see Fast CI) |
--refresh |
— | Run every mutant without reusing anything, and store the results for later incremental runs |
--max-duration SECONDS |
max-duration from config (none) |
Start no new mutant after this many seconds; finished results are saved |
--fail-under PERCENT |
— | Exit with code 1 if the mutation score is below this value |
--baseline PATH |
— | JSON file of accepted survivors; exit with code 1 on any other survivor |
--update-baseline |
— | Write the current survivors to the --baseline file instead of checking it |
typemut init
| Option | Default | Description |
|---|---|---|
--config PATH |
typemut.toml |
Config file |
--db PATH |
db from config (typemut.sqlite) |
Database file |
typemut exec
| Option | Default | Description |
|---|---|---|
--config PATH |
typemut.toml |
Config file |
--db PATH |
db from config (typemut.sqlite) |
Database file |
--jobs N |
1 |
Number of parallel workers |
--incremental / --no-incremental |
incremental from config (off) |
Reuse kills of earlier runs whose files are unchanged |
--refresh |
— | Run the pending mutants without reusing cached kills, and store the results for later incremental runs (exec only runs pending mutants: use run --refresh, or init first, to recheck every mutant) |
--max-duration SECONDS |
max-duration from config (none) |
Start no new mutant after this many seconds |
typemut report
| Option | Default | Description |
|---|---|---|
--db PATH |
typemut.sqlite |
Database file |
--fail-under PERCENT |
— | Exit with code 1 if the mutation score is below this value |
--baseline PATH |
— | JSON file of accepted survivors; exit with code 1 on any other survivor |
--update-baseline |
— | Write the current survivors to the --baseline file instead of checking it |
typemut html
| Option | Default | Description |
|---|---|---|
--db PATH |
typemut.sqlite |
Database file |
-o, --output PATH |
typemut-report.html |
Output file |
--open |
— | Open the report in a browser |
Using in CI
typemut run and typemut report exit with code 0 regardless of survivors unless
you ask them to gate:
typemut run --fail-under 90 # exit 1 if the mutation score is below 90%
typemut run --baseline typemut-baseline.json # exit 1 on survivors not in the baseline
typemut run --baseline typemut-baseline.json --update-baseline # accept the current survivors
A baseline is a JSON file of survived mutants you have reviewed and accept
(e.g. equivalent mutants the type checker can never kill). Commit it and run with
--baseline in CI: known survivors pass, new ones fail the build and are listed.
Entries are matched by file, operator, original and mutated annotation, and the
text of the source line — not its number, so edits elsewhere in a file keep the
baseline valid. Entries that no longer survive are reported; rerun with
--update-baseline to drop them.
run and report also exit with code 1 while any mutant is still pending (a run
stopped by --max-duration, an interrupted exec), and then do not update the
baseline: a gate on incomplete results would pass by accident.
Mutation testing is slow, so use --jobs N in CI (it needs a clean git working tree,
see Quick Start), and --incremental with the database kept
between CI runs (below). typemut runs this on itself — see make mutate and
.github/workflows/ci.yml.
Fast CI: incremental runs
typemut run --incremental reuses the kills of earlier runs stored in the same
database, and runs only the mutants that are new, survived, ended in error,
or whose kill may no longer hold:
typemut run --incremental --jobs 4 --baseline typemut-baseline.json
What is reused. A killed mutant is identified by its file, line, column,
operator, original and mutated annotation and required import. When it is
killed, typemut records the files the kill depends on, with a hash of each: the
mutated file, the files the checker reported errors in, and every project file
those import (directly or not). The next incremental run copies the kill
(status and checker output) if all those files are unchanged, and prints
Reused N cached kills.
What always runs again. Survived and error mutants; kills by timeout;
kills whose errors are outside the project root (in installed packages) or
whose output names no file (a test-command that cds before running the
checker); every mutant under the generic checker, which cannot tell the
files a kill depends on (the run says so, and stores nothing in the cache); kills whose files changed (the run prints
how many). The baseline check always runs too: it catches a broken tree and
warms the checker's cache.
When the whole cache is dropped. The cache is reset, with the changed input
named (Result cache reset: checker version changed.), when any of these differ
from the run that filled it: the typemut version (and, for unreleased builds,
its sources), the checker and its version, the version of the Python it runs
with, test-command, timeout, the checker's config files (mypy.ini,
pyproject.toml, pyrightconfig.json, ...) and the files matched by
cache-key-files (by default the lockfiles uv.lock, poetry.lock,
requirements*.txt). Patterns in cache-key-files are relative to the
project root; absolute, empty and invalid ones are skipped with a warning. If the checker's
version is unknown (see Type checkers; set
checker-version-command), that run neither reuses nor stores results and
leaves the cache as it is, so a single failed version check does not discard
it: the next run with a known version reuses the kills again. The Python version is that of
python --version, run through the project runner the checker runs with
(uv run python --version for uv run mypy, python3.12 --version for
python3.12 -m mypy) or from the directory of a checker run by path
(.venv/bin/python --version for .venv/bin/mypy).
Keeping the cache fresh. typemut run --refresh runs every mutant and
stores the results without reusing any: run it on a schedule (e.g. weekly) to
recheck the kills. (exec --refresh only runs the mutants still pending.)
Under --max-duration a refresh rechecks only the mutants that fit: they run
in a fixed order, so the ones past the limit are never rechecked and keep
their old kills. Give the scheduled refresh a time limit (and CI timeout)
long enough for the whole project, or none. Without --incremental nothing is reused or stored, and the kills
already in the result cache stay there: a run without the flag (or init)
between two incremental runs does not discard them.
Time limits. --max-duration SECONDS (or max-duration in the config)
stops starting new mutants after that many seconds, waits for the running
ones and saves every finished result; the run then fails with
N mutants not run yet. Keep it below the CI job timeout. A run
stopped by SIGTERM (as CI runners stop jobs) saves its finished results too.
On a project with no cache yet, a first run may not fit in the CI timeout:
store the database after every run on the main branch (even a failed one), and
each next incremental run continues from the kills of the previous ones until
the whole project is covered.
The cache lives in the database file (typemut.sqlite by default): keep it
out of git (it is binary and changes on every run; with --jobs an untracked
file in the project fails the clean-tree check, so list it in .gitignore or
pass --db outside the project) and carry it between CI runs as a build
artifact or cache (see below).
Limits. Reuse trusts the checker's output and typemut's import graph. For
every imported project module both its .py file and its .pyi stub are
tracked, so adding a stub next to a module (or a module next to a stub)
reruns the kills that depend on it. A file the checker reads that is not
imported (a plugin config, a stub found through mypy_path outside the
project, a new module that shadows another on the search path) is not
tracked; list it in
cache-key-files if a change to it can turn a kill into a survivor. For a
checker run by name without a project runner (mypy src, npx pyright), the
Python version is that of the python on PATH, which may not be the one the
checker runs with (a pipx-installed mypy), or may be missing (systems with only
python3): then a Python upgrade does not reset the cache; run the checker
through its environment (uv run mypy, .venv/bin/mypy) or use --refresh.
Keeping the database between CI runs
An incremental run is only as fast as the database it starts from, and a CI
job starts from a fresh checkout. Carry typemut.sqlite from run to run with
these rules:
- Not in git. The database is binary, changes on every run, and an
untracked or modified file in the project fails the clean-tree check of
--jobs. Keep*.sqlitein.gitignore(or pass--dboutside the project). - Only trusted runs on the default branch store it (push, schedule, manual runs). Pull/merge requests and other branches only read it: a cached kill is copied as is, so a database written by an untrusted change could hide survivors from later runs.
- Every run, the default branch included, starts from the latest database of
the default branch, whatever that run's outcome. A run that failed (a new
survivor) or was stopped by
--max-durationsaved its finished results, and the next run continues from them. That is how a project too large for one CI job gets covered: each run on main adds kills until none are left to run. - No database means a full run: correct, only slower. An expired artifact or an evicted cache costs one slow run, nothing else.
- Time limits nest:
--max-duration< the step's (script's) timeout < the job's timeout, so typemut stops starting mutants, waits for the running ones and saves, and the upload step still runs. Leave room for one mutant'stimeoutplus the baseline check between--max-durationand the step timeout. - Runs on the default branch wait for each other. Two overlapping runs
would both upload a database, and the older commit's run could finish
last and become "the latest". Queue them (a concurrency group on GitHub, a
resource_groupon GitLab), so a queued run downloads the database of the run before it when it starts, and pick the newest run's database, not the newest upload (the artifact examples do; the GitHub cache example restores the newest upload, see its caveat). A queued run waits for the one ahead of it, so a push that arrives during the weekly refresh waits until the refresh is done. - Recheck on a schedule: a weekly
typemut run --refreshon the default branch runs every mutant again and stores the results like a push. It rechecks only what fits in its--max-duration, so give it a limit (and job timeout) long enough for the whole project, within the platform's maximum job time: the GitHub examples use 5.5 hours, the GitLab one 5h40m, instead of 25 minutes.
typemut's own CI does this with a GitHub artifact: see
.github/workflows/ci.yml. (There, any change to typemut's own sources resets
the cache, since an unreleased typemut is keyed by its sources: pull requests
that touch src/typemut/ run every mutant.)
GitHub Actions: artifact
Artifacts are kept for retention-days (up to the repository's limit, 90 days
by default), can be found by name through the API, and a failed or timed-out
run still uploads one with if: always(). The download step looks for the
unexpired typemut-db artifact of the newest run (highest run id) on main
of this repository: head_repository_id keeps out a fork's pull request from
a branch that is also named main, and the run id keeps out a re-run of an
old run, which uploads later than newer runs. It reads the 100 most recently
uploaded typemut-db artifacts, which is plenty with one per run on main.
The concurrency group queues runs of the same branch, so a run on main
starts after the previous one has uploaded. GitHub keeps only one waiting run
per group, and a newer one cancels it. For a waiting push run that is fine:
the newer run downloads the same database and covers its commit. But a push
can also cancel a waiting weekly refresh (or wait for a running one, up to its
time limit); re-trigger a cancelled refresh by hand with the workflow's
refresh input. Don't give refreshes a group of their own: a push run started
during a refresh has a higher run id, so its database, built without the
refresh's results, would become the latest. overwrite: true lets a re-run of failed jobs, which reuses the
run, replace that run's artifact. The weekly --refresh run gets a 5.5-hour
limit (a GitHub-hosted job runs for at most 6 hours).
name: typemut
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 3 * * 1" # weekly --refresh
workflow_dispatch:
inputs:
refresh: # re-trigger a refresh replaced in the queue
type: boolean
default: false
permissions:
contents: read
jobs:
typemut:
runs-on: ubuntu-latest
# > step timeout > --max-duration; the weekly --refresh gets longer.
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 350 || 45 }}
concurrency: # runs on main upload one after another
group: typemut-db-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
actions: read # list and download artifacts
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync
- name: Download the typemut database of main
env:
GH_TOKEN: ${{ github.token }}
REPO_ID: ${{ github.repository_id }}
run: |
# Unexpired artifact of the newest run on main of this repository
# (not of a fork), whatever that run's outcome.
query='[.artifacts[]
| select(.expired | not)
| select(.workflow_run.head_branch == "main")
| select(.workflow_run.head_repository_id == ($ENV.REPO_ID | tonumber))]
| max_by(.workflow_run.id) // empty
| "\(.id) \(.workflow_run.id)"'
if ! found="$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=typemut-db&per_page=100" --jq "$query")"; then
echo "::warning::Could not list the typemut-db artifacts: running every mutant"
exit 0
fi
if [ -z "$found" ]; then
echo "::notice::No typemut database of main: running every mutant"
exit 0
fi
read -r artifact_id run_id <<< "$found"
if gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" > typemut-db.zip \
&& unzip -o -q typemut-db.zip typemut.sqlite; then
echo "::notice::typemut database from run $run_id"
else
echo "::warning::Could not download the database of run $run_id: running every mutant"
rm -f typemut.sqlite
fi
rm -f typemut-db.zip
- name: typemut
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 340 || 35 }}
env:
ARGS: ${{ (github.event_name == 'schedule' || inputs.refresh) && '--refresh --max-duration 19800' || '--max-duration 1500' }}
run: >-
uv run typemut run --incremental --jobs 4
--baseline typemut-baseline.json $ARGS
# Only trusted events on main store the database, even when typemut failed.
- name: Upload the typemut database
if: always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: typemut-db
path: typemut.sqlite
retention-days: 30
if-no-files-found: ignore
overwrite: true # a re-run of failed jobs reuses the run
GitHub Actions: cache
Simpler, with no API calls, but caches unused for 7 days are evicted and the
repository's cache storage is limited (10 GB by default, oldest evicted first).
Each run on main saves a new cache under a unique key; every run restores the
newest one by the typemut- prefix. Pull requests can restore caches of the
base branch, and caches saved by pull requests stay scoped to them, so main
never reads one. The triggers, time limits and concurrency group are those of
the artifact workflow; only the database steps differ (no actions: read
needed).
The caveat: the restore step takes the newest saved cache, not the newest run's. The concurrency group does not stop a re-run of an older run on main (say, "Re-run failed jobs" on a run that failed): it saves last, so later runs restore it instead of the newer runs' caches. Nothing wrong gets reused, since kills are only copied for files whose hashes still match (and a changed typemut, checker version or lockfile resets the cache), but the progress of the runs in between is lost and their mutants run again. Re-run only the newest run on main, or use the artifact variant, which picks the newest run.
name: typemut
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 3 * * 1" # weekly --refresh
workflow_dispatch:
inputs:
refresh: # re-trigger a refresh replaced in the queue
type: boolean
default: false
permissions:
contents: read
jobs:
typemut:
runs-on: ubuntu-latest
# > step timeout > --max-duration; the weekly --refresh gets longer.
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 350 || 45 }}
concurrency: # runs on main save one after another
group: typemut-db-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync
- uses: actions/cache/restore@v4
with:
path: typemut.sqlite
key: typemut-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: typemut- # the newest cache saved on main
- name: typemut
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 340 || 35 }}
env:
ARGS: ${{ (github.event_name == 'schedule' || inputs.refresh) && '--refresh --max-duration 19800' || '--max-duration 1500' }}
run: >-
uv run typemut run --incremental --jobs 4
--baseline typemut-baseline.json $ARGS
- uses: actions/cache/save@v4
if: always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
with:
path: typemut.sqlite
key: typemut-${{ github.run_id }}-${{ github.run_attempt }}
GitLab CI: artifact
Three jobs share the script: typemut runs on the default branch and keeps the
database as an artifact even when it fails (when: always); typemut-mr runs
for merge requests and keeps nothing; typemut-refresh is the weekly
--refresh from a pipeline schedule, with a longer time limit so that it
rechecks the whole project (raise the project's and the runner's maximum job
timeout to match). All download the artifact of the typemut or
typemut-refresh job of the newest default-branch pipeline in which that job
succeeded or failed: GitLab's "latest artifact of a branch" endpoint only knows
successful pipelines, so the job is looked up through the API, in the 50 newest
pipelines of the default branch (listed by the server, so merge request
pipelines do not push them out). A pipeline still running other jobs (a later
stage, a deploy waiting on a manual job) counts once its typemut job is done;
pipelines are ordered by id, so a retried job of an older pipeline does not
win. With none among them, the run is a full one.
resource_group: typemut-db is GitLab's counterpart to the GitHub concurrency
group: the typemut and typemut-refresh jobs run one at a time, and a job
waiting for the group downloads the database when its script starts, after
the job ahead of it has uploaded. A push pipeline that starts during the
weekly refresh therefore waits for it (up to its 6h timeout). The group's
default process mode lets waiting jobs start in any order; set it to
oldest_first (curl --request PUT --header "PRIVATE-TOKEN: ..." --data "process_mode=oldest_first" "$CI_API_V4_URL/projects/<id>/resource_groups/typemut-db")
so they run in pipeline order and the newest pipeline's database is also the
last one written. Listing pipelines and jobs needs a token with the
read_api scope (a project access token with the Reporter role, saved as the
masked CI/CD variable TYPEMUT_API_TOKEN; leave it unprotected so merge
request pipelines see it). Without it the script falls back to the job token
and the typemut job of the latest successful pipeline. curl --fail fetches the single file, so
no zip is involved; a missing artifact (or a failed download, whose partial
file is removed) just means a full run.
RUNNER_SCRIPT_TIMEOUT below timeout leaves the runner time to upload the
artifact after a script timeout; it needs GitLab Runner 16.4 or later (older
runners ignore it, and a job that hits timeout uploads nothing).
.typemut:
image: ghcr.io/astral-sh/uv:python3.13-bookworm
timeout: 45m
variables:
RUNNER_SCRIPT_TIMEOUT: 35m # < timeout, > --max-duration (Runner 16.4+)
TYPEMUT_MAX_DURATION: "1500"
script:
- uv sync
- |
api="$CI_API_V4_URL/projects/$CI_PROJECT_ID"
if [ -n "${TYPEMUT_API_TOKEN:-}" ]; then
# Newest `typemut`/`typemut-refresh` job on the default branch,
# successful or failed, in its 50 newest pipelines.
job_id="$(python3 - <<'PY'
import json, os, urllib.parse, urllib.request
api = os.environ["CI_API_V4_URL"] + "/projects/" + os.environ["CI_PROJECT_ID"]
def get(path):
headers = {"PRIVATE-TOKEN": os.environ["TYPEMUT_API_TOKEN"]}
with urllib.request.urlopen(urllib.request.Request(api + path, headers=headers)) as response:
return json.load(response)
ref = urllib.parse.quote(os.environ["CI_DEFAULT_BRANCH"], safe="")
for pipeline in get(f"/pipelines?ref={ref}&order_by=id&sort=desc&per_page=50"):
jobs = get(f"/pipelines/{pipeline['id']}/jobs?scope[]=success&scope[]=failed&per_page=100")
found = [job["id"] for job in jobs if job["name"] in ("typemut", "typemut-refresh")]
if found:
print(found[0])
break
PY
)" || job_id=""
if [ -n "$job_id" ]; then
curl --fail --location -sS --header "PRIVATE-TOKEN: $TYPEMUT_API_TOKEN" \
"$api/jobs/$job_id/artifacts/typemut.sqlite" -o typemut.sqlite \
&& echo "typemut database from job $job_id" \
|| { echo "no previous DB"; rm -f typemut.sqlite; }
else
echo "no previous DB"
fi
else
# Latest successful pipeline of the default branch only.
curl --fail --location -sS --header "JOB-TOKEN: $CI_JOB_TOKEN" \
"$api/jobs/artifacts/$CI_DEFAULT_BRANCH/raw/typemut.sqlite?job=typemut" -o typemut.sqlite \
|| { echo "no previous DB"; rm -f typemut.sqlite; }
fi
- uv run typemut run --incremental --jobs 4 --max-duration $TYPEMUT_MAX_DURATION
--baseline typemut-baseline.json $TYPEMUT_EXTRA_ARGS
.typemut-store:
extends: .typemut
resource_group: typemut-db # one job at a time, like GitHub's concurrency group
artifacts:
paths: [typemut.sqlite]
when: always # failed and timed-out runs store their results too
expire_in: 30 days
typemut:
extends: .typemut-store
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
typemut-refresh: # weekly pipeline schedule on the default branch
extends: .typemut-store
timeout: 6h
variables:
RUNNER_SCRIPT_TIMEOUT: 5h50m
TYPEMUT_MAX_DURATION: "20400" # 5h40m: a refresh rechecks only what fits
TYPEMUT_EXTRA_ARGS: "--refresh"
rules:
- if: $CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
typemut-mr:
extends: .typemut
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
GitLab CI: cache
Works with caveats, so prefer artifacts:
- By default protected branches (the default branch) and unprotected ones
(merge request branches) get separate caches, so a merge request never sees
the cache of the default branch. Turning off Use separate caches for
protected branches fixes that, but then any branch's job can write the
cache the default branch reads, unless every job but the default branch's
uses
policy: pull. fallback_keysneeds GitLab 16.0 or later, a variable incache:policy16.1 or later,$CI_DEFAULT_BRANCH_SLUG(the fallback key must be slugged like the key the default branch saves under) 17.4 or later, andRUNNER_SCRIPT_TIMEOUTGitLab Runner 16.4 or later.- Without a distributed cache (S3, GCS, ...), each runner has its own cache, and a job on another runner starts from nothing.
typemut:
image: ghcr.io/astral-sh/uv:python3.13-bookworm
timeout: 45m
variables:
RUNNER_SCRIPT_TIMEOUT: 35m
cache:
key: typemut-db-$CI_COMMIT_REF_SLUG
fallback_keys: [typemut-db-$CI_DEFAULT_BRANCH_SLUG]
paths: [typemut.sqlite]
when: always
policy: $TYPEMUT_CACHE_POLICY
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
TYPEMUT_CACHE_POLICY: pull-push
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
TYPEMUT_CACHE_POLICY: pull # merge requests only read the cache
script:
- uv sync
- uv run typemut run --incremental --jobs 4 --max-duration 1500 --baseline typemut-baseline.json
Type checkers
typemut runs test-command as is, but it reads the output with a plugin for the
type checker that command runs. The plugin tells a real kill from a mutant that
merely broke the code: a failure whose only errors are a missing name, a syntax
error or an invalid type is recorded as error, not killed. It also finds the
checker's config files and cache directory. The checker's version, the files
with errors and which project files a file imports are known to the plugin
too: incremental runs use them to tell whether
an earlier kill can be reused.
checker |
Recognised executables | Errors counted as broken code |
|---|---|---|
mypy |
mypy, dmypy |
[name-defined], [syntax], [valid-type] |
pyright |
pyright |
reportUndefinedVariable, reportInvalidTypeForm, errors without a rule (syntax errors) |
basedpyright |
basedpyright |
same as pyright (a subclass: the output format is the same) |
generic |
— | mypy-style [code] at the end of any output line, as above |
By default (checker = "auto") the checker is detected from test-command:
a recognised executable counts when it is the command itself, possibly after
environment assignments, env/exec/time, or a runner (uv run,
poetry run, pipx run, pdm run, hatch run, rye run, pnpm exec,
uvx, npx, bunx, python -m), in any command of a chain
(uv run mypy src, python -m mypy -p app, cd src && npx pyright).
Runner options are skipped, with their values (uv run --group dev mypy,
npx -p pyright pyright). An
executable name passed to another tool (tox -e mypy,
pre-commit run mypy --all-files) is not detected. A command that runs none
of them (make typecheck, a script, a wrapper like tox), or more than one
(mypy src && pyright src), gets the generic checker and a warning; name the
checker instead:
[typemut]
test-command = "make typecheck"
checker = "mypy"
The run prints the checker it uses, with the config files it found and the
cache directory it would use (and, if configured as a relative path inside
the project, copy into each worktree with --jobs), relative to the project
root (Type checker: mypy (config: pyproject.toml; cache: .mypy_cache)); the
cache is listed even before mypy has created it. mypy's cache directory comes
from --cache-dir, MYPY_CACHE_DIR, cache_dir in its config, or
.mypy_cache, with ~ and $VAR expanded as mypy does.
An unknown checker name is logged as a warning and generic is used. mypy's
--pretty, --show-column-numbers and --show-error-end output is understood;
only errors count (mypy notes, pyright warnings and information are ignored).
Both stdout and stderr are parsed, so noise from wrappers such as uv run on
stderr does not hide the checker's errors.
Incremental runs reuse earlier kills only while the checker's version is the
same, and do not use the cache at all while it is unknown. It is taken from test-command up to the checker executable plus --version,
as written there (cd backend && uv run mypy . -> cd backend && uv run mypy --version:
of the commands before the checker only cd and pushd are kept). If the
command does not run the executable directly (tox -e mypy, make typecheck),
set checker-version-command, e.g. checker-version-command = "npx pyright --version";
the first X.Y.Z in its output is the version. generic only has a version
when checker-version-command is set, but it never reuses kills anyway: it
cannot tell which files they depend on.
Third-party checkers
A package can ship a checker plugin: subclass typemut.checkers.base.TypeChecker
and register it under the typemut.checkers entry point group. The entry point
name is the value for checker = ...:
# pyproject.toml of the plugin package
[project.entry-points."typemut.checkers"]
pytype = "typemut_pytype:PytypeChecker"
from collections.abc import Iterable
from typemut.checkers.base import Diagnostic, TypeChecker
class PytypeChecker(TypeChecker):
name = "pytype"
executables = frozenset({"pytype"})
def parse_output(self, output: str) -> Iterable[Diagnostic]:
... # one Diagnostic(path, code) per error
Set name, executables (used for detection and the version command),
false_kill_codes and config_names (config files the checker reads from the
project root), and override parse_output(output) to return a
Diagnostic(path, code) (from typemut.checkers.base) for every error in one
output stream. executables should not overlap those of another checker:
checker = "auto" falls back to generic when a command matches two
unrelated checkers. The one exception is a subclass of a checker that shares
its executables (e.g. class StrictMypyChecker(MypyChecker) with the
inherited mypy): auto-detection then picks the subclass instead of its base.
Two such subclasses of the same checker installed together match both, so set
checker = "<name>" explicitly. config_files, cache_paths (cache
directories inside the project), worktree_cache_paths (those copied into
each worktree; by default all of cache_paths, mypy leaves out a cache
configured by an absolute path), python_version and dependencies (by
default: the project files reachable through imports; None means that kill
is not reused) can be overridden too. A checker that can never tell which files a kill depends on
(e.g. its output carries no file paths) should set
traces_dependencies = False: incremental runs then neither reuse nor store
its results and leave the cache alone, as with generic. The constructor takes
(test_command, version_command, runner, graph=None, environ=None); environ
is the environment the checker runs with (the process environment by default). An entry point that fails to import
or does not name a TypeChecker subclass is logged as a warning and skipped.
What It Finds
typemut generates mutations of type annotations and checks whether the type checker catches them. Each mutation operator targets a specific class of type safety issues.
RemoveUnionMember
Removes one member from a union type.
# Original
def handle(value: int | str | float) -> None: ...
# Mutant: remove str
def handle(value: int | float) -> None: ...
Survived = your code doesn't distinguish between union members. If removing str from the union causes no type error, it means no code path relies on value being a str. The union may be overly broad, or the type checker doesn't see the code that handles str specifically.
RemoveOptional
Removes None from X | None.
# Original
def find_user(id: int) -> User | None: ...
# Mutant
def find_user(id: int) -> User: ...
Survived = callers don't check for None. The return type says "might be None" but no consumer's type annotations actually require a None-check. Either the None case is dead code, or callers use # type: ignore.
AddOptional
Adds | None to return types and class fields (parameters are excluded — callers simply won't pass None, making those mutations uninformative).
# Original
class Config:
name: str
# Mutant
class Config:
name: str | None
Survived = consumers don't rely on non-None guarantee. The field claims to always have a value, but no typed code would break if it could be None. This often reveals missing type coverage in code that reads the field.
WidenType
Replaces a concrete class with its parent (base) class to find places where a more abstract type could be used.
class Animal: ...
class Cat(Animal): ...
class Dog(Animal): ...
# Original
def feed(pet: Cat) -> None: ...
# Mutant
def feed(pet: Animal) -> None: ...
Survived = the code doesn't rely on the concrete subclass. The function could accept the broader base type, suggesting the annotation is more specific than necessary.
RemoveLiteralMember
Removes one value from a multi-value Literal[...].
# Original
def open_file(mode: Literal["r", "w", "a"]) -> None: ...
# Mutant: remove "a"
def open_file(mode: Literal["r", "w"]) -> None: ...
Survived = that literal value is never used. No typed caller passes "a", so the Literal is broader than the code actually needs.
WidenContainerType
Replaces a concrete container with the next more abstract type.
# Original
def total(items: list[int]) -> int: ...
# Mutant
def total(items: Sequence[int]) -> int: ...
Widening steps: list/tuple → Sequence, set/frozenset → AbstractSet, dict → Mapping, Sequence/AbstractSet/Mapping → Collection, Collection → Iterable. tuple[X, ...] becomes Sequence[X], Mapping[K, V] becomes Collection[K], and heterogeneous tuples like tuple[int, str] are skipped.
Survived = the code only uses the abstract interface. A parameter could accept the wider type; a return type could promise less.
SwapIteratorGenerator
Widens iterator/generator types by one step: Iterator → Iterable, Generator[Y, S, R] → Iterator[Y], and the async equivalents.
# Original
def read_lines(path: str) -> Generator[str, None, None]: ...
# Mutant
def read_lines(path: str) -> Iterator[str]: ...
Survived = nobody uses the extra capability. Callers never call next() / send() or read the generator's return value, so the simpler type is enough.
TypeVarVariance
Removes covariant=True / contravariant=True from a TypeVar, or adds either one to an invariant TypeVar.
# Original
T_co = TypeVar("T_co", covariant=True)
# Mutant
T_co = TypeVar("T_co")
Survived = variance is not relied on (removal), or the TypeVar could be declared variant (addition).
Plugins
Core operators know nothing about libraries. Some libraries wrap your types in their own generics and read them at runtime, which changes what a mutation means. Plugins teach typemut these rules. They are opt-in:
[typemut]
plugins = ["sqlalchemy", "fastapi"]
An unknown plugin name is logged as a warning and skipped. A plugin can claim an annotation and decide its mutations (usually by running the core operators on a wrapped type and dropping mutations that are invalid for the library), and can contribute its own operators. Annotations no plugin claims are mutated as usual.
Third-party plugins
A package can ship its own plugin: subclass typemut.plugins.base.Plugin and
register it under the typemut.plugins entry point group. The entry point name
is the name used in plugins = [...]:
# pyproject.toml of the plugin package
[project.entry-points."typemut.plugins"]
django = "typemut_django:DjangoPlugin"
An entry point that fails to import or does not name a Plugin subclass is
logged as a warning and skipped.
A plugin's find_mutations receives the enabled operators and the class
registry. The Plugin base class also provides parso helpers
(_node_code, _trailer_target) that a plugin can use or override. Operators
a plugin returns from operators() subclass TypeMutationOperator, which
provides _node_code, _is_qualified_name, _union_members and _type_params.
The data types plugins and operators work with (AnnotationNode,
AnnotationContext, Mutation) live in typemut.model.
A plugin operator's config_key can be used in [typemut.operators] and
[typemut.ignore-types] like a built-in operator key. Keys that no available
operator has are reported as a warning and have no effect.
sqlalchemy
For SQLAlchemy 2.0 declarative models (Mapped[...], also orm.Mapped[...]).
SQLAlchemy derives the column type, nullability and relationship target from
the type inside Mapped[...], so the plugin mutates that type instead of the
whole annotation:
| Annotation | Without plugin | With plugin |
|---|---|---|
id: Mapped[int] |
Mapped[int] | None (meaningless) |
Mapped[int | None] (nullable column) |
email: Mapped[str | None] |
not mutated | Mapped[str] |
user: Mapped["UserDB"] = relationship() |
Mapped["UserDB"] | None |
Mapped["UserDB | None"] |
profile: Mapped["ProfileDB | None"] = relationship() |
Mapped["ProfileDB | None"] | None |
Mapped["ProfileDB"] |
roles: Mapped[list[RoleDB]] = relationship() |
Mapped[Sequence[RoleDB]] |
skipped |
payload: Mapped[dict[str, Any]] |
Mapped[Mapping[str, Any]] |
skipped |
user: Mapped[UserDB] = relationship() (UserDB(BaseDB)) |
Mapped[BaseDB] |
skipped |
Rules:
- Quoted forward references (
Mapped["UserDB | None"]) are mutated inside the quotes. WidenContainerTypeskips the top-level type insideMapped[...]: SQLAlchemy needs a concrete column type / collection class there (list,set), not an ABC. Nested containers (Mapped[dict[str, list[int]]]) are still widened.- On relationship attributes (
= relationship(...)):WidenTypeis skipped: the target must be a mapped class, and widening usually points at the declarative base;AddOptionalis skipped for collections (Mapped[list[X]]): a collection relationship is an empty collection, neverNone.
WriteOnlyMapped[...]andDynamicMapped[...]get no mutations: they are always relationship collections, and none of the core mutations is valid for them.
Nullability mismatches between Mapped[X | None] and mapped_column(nullable=...)
are a runtime/schema issue, not a typing one, so the plugin does not generate them.
fastapi
FastAPI calls route handlers itself, and tests reach them over HTTP, so no Python
code calls them. The type checker has no caller to check a mutated signature
against: -> RedirectResponse → -> RedirectResponse | None always survives.
The plugin skips all parameter and return annotations of such functions:
from fastapi import APIRouter
router = APIRouter()
@router.get("/login")
async def login(next: str | None = None) -> RedirectResponse: # not mutated
target: str | None = next # still mutated
...
A function counts as an endpoint when it is decorated with
@<receiver>.<method>(...), where <method> is a route method (get, post, put,
patch, delete, head, options, trace, api_route, websocket) or
exception_handler, middleware, on_event. The receiver has to be an instance
of fastapi.FastAPI / fastapi.APIRouter, or of a project class inheriting from
them (also across modules). The type is resolved statically from imports, not
from variable names:
app = FastAPI(),router: APIRouter = make_router(),def register(app: FastAPI);app = FastAPI()inside an app factory function;- a receiver imported from another project module, including re-exports
(
from app.api import router,from app import api+@api.router.get).
Unresolvable receivers (a factory call without annotation, self.router, a star
import) are not treated as endpoints. The same goes for functions registered
without a decorator (app.add_api_route(...)) or used only through Depends(...).
Mark those with # pragma: no mutate.
Other frameworks: [typemut.entrypoints]
Other frameworks register handlers the same way: in-house RPC layers, message
consumers, task queues. Name them in [typemut.entrypoints] to skip the
parameter and return annotations of their handlers, without a plugin per
framework (no entry in plugins needed):
[typemut.entrypoints]
# functions decorated with one of these
decorators = ["mylib.rpc.*.method", "faststream.*.subscriber", "taskiq.*.task"]
# methods of classes inheriting from one of these
base-classes = ["mylib.rpc.Endpoint"]
# functions with a parameter whose annotation references one of these (DI markers)
parameter-types = ["mylib.rpc.*.Dependency", "dishka.FromDishka"]
from mylib.rpc import Endpoint, Router
rpc = Router()
@rpc.method() # mylib.rpc.Router.method
async def get_item(item_id: int, service: rpc.Dependency[ItemService]) -> ItemSchema: # not mutated
item: ItemSchema | None = await service.get(item_id) # still mutated
...
class Items(Endpoint):
async def get_item(self, item_id: int) -> ItemSchema: ... # not mutated
Patterns are fnmatch globs (* matches dots too) against qualified names,
resolved statically like the fastapi plugin does (imports, aliases, relative
imports, re-exports and objects imported from other project modules):
- decorators:
@x,@x.yor@x.y(...). The decorator matches as the qualified name of what it refers to (from taskiq import task→taskiq.task), and, when its receiver is an instance (rpc = Router(),rpc: Router = make(),def register(rpc: Router)), as<class>.<attribute>for the instance's class and each of its base classes:@rpc.method()→mylib.rpc.Router.method, also withrpc = AppRouter()whereclass AppRouter(Router)is a project class. Classes are named as they are imported (from mylib.rpc import Router→mylib.rpc.Router), not where they are defined. - base-classes: methods (not nested functions) of a class whose bases inherit from a matching class, directly or through project classes in other modules. Methods of the matching class itself are mutated.
- parameter-types: every name the annotation references counts
(
Dependency[Service],Annotated[Service, Inject]), resolved like decorators:rpc.Dependencywithrpc = Router()ismylib.rpc.Router.Dependency. String annotations and default values (= Depends(...)) are not looked at. - Unresolvable names (factory calls,
self.router, star imports) don't match. - Unknown keys and invalid values are logged as warnings and skipped.
Filtering
Annotations are automatically skipped when:
- The line contains
# type: ignoreor# pragma: no mutate - The annotation is
Any(mutations are meaningless — Any absorbs all types) AddOptionaltargets a function parameter (low signal — callers won't pass None)
Ignoring library types per operator
By default every operator mutates every annotation, including library wrapper
types it knows nothing about (Mapped[int] → Mapped[int] | None). To silence
such noise, list the types an operator must not touch under
[typemut.ignore-types], keyed by operator (same keys as [typemut.operators]):
[typemut.ignore-types]
all = ["django.db.models.*"] # every operator
add-optional = ["sqlalchemy.orm.Mapped"]
remove-optional = ["sqlalchemy.orm.Mapped"]
widen-container-type = ["sqlalchemy.orm.Mapped"]
widen-type = ["sqlalchemy.orm.DeclarativeBase"]
- An operator skips every annotation that references one of its ignored types
(anywhere in it, e.g.
list[Mapped[int]]); other operators still mutate it. - An operator also drops mutations that would introduce an ignored type, e.g.
WidenTypewidening a model toDeclarativeBase. - Patterns are
fnmatchglobs matched against the qualified name the type is imported under in the file (from sqlalchemy.orm import Mapped→sqlalchemy.orm.Mapped), not where it is defined. Aliases (import sqlalchemy.orm as orm,orm.Mapped) and relative imports of your own packages are resolved. - Names that aren't imported (builtins, classes defined in the same file) are never ignored.
- Plugins see annotations first and are not affected: with
plugins = ["sqlalchemy"],Mapped[...]is still mutated correctly (Mapped[int]→Mapped[int | None]). - Unknown operator keys and invalid values are logged as warnings and skipped.
Ignoring fields of class hierarchies per operator
Some fields are equivalent mutants by design: on response or serialization
models nobody reads the fields in typed code, so AddOptional, WidenType and
WidenContainerType on them always survive, while RemoveOptional and
RemoveUnionMember still catch real mismatches. List the classes whose fields
an operator must not touch under [typemut.ignore-classes], keyed by operator
(same keys as [typemut.operators]):
[typemut.ignore-classes]
add-optional = ["myproject.api.BaseResponse"]
widen-type = ["myproject.api.BaseResponse"]
widen-container-type = ["myproject.api.BaseResponse"]
all = [] # every operator
- An operator skips the fields (class-level annotations,
name: str) of every class that is, or inherits from, one of its ignored classes, directly or through any number of project classes, in any module. Other operators still mutate them. - Parameters, return types and local variables of the methods of those classes are mutated as usual.
- Patterns are
fnmatchglobs matched against qualified names: the class's own (myproject.api.ItemResponse; nested classes asmodule.Outer.Inner) and those of all its bases. A base is named the way it is imported (from pydantic import BaseModel→pydantic.BaseModel), a project class also by the module it is defined in, so a class re-exported from a package's__init__.pymatches bothmyproject.api.BaseResponseandmyproject.api.base.BaseResponse. - Plugins see annotations first and are not affected.
- Unknown operator keys and invalid values are logged as warnings and skipped.
Config Reference
[typemut]
module-path = "src/myproject" # directory to scan for annotations
test-command = "make typecheck" # command to run type checker
timeout = 30 # seconds per mutation
excluded-modules = ["src/vendor/*.py"] # glob patterns to skip
skip-comments = ["type: ignore", "pragma: no mutate"]
db = "typemut.sqlite" # database file
plugins = ["sqlalchemy"] # library plugins, none by default
checker = "mypy" # mypy, pyright, basedpyright, generic; default "auto" detects it
# from test-command (set it when test-command is a wrapper like make)
# checker-version-command = "mypy --version" # optional; for incremental runs;
# default: test-command up to the checker + --version
incremental = false # reuse kills of earlier runs (--incremental / --no-incremental)
# max-duration = 1500 # seconds; start no new mutant after it (--max-duration)
cache-key-files = ["uv.lock", "poetry.lock", "requirements*.txt"] # glob patterns relative to the
# project root (absolute, empty or invalid ones are skipped with a warning);
# a change to any drops every cached result
[typemut.operators]
# all enabled by default, disable selectively
remove-union-member = true
remove-literal-member = true
widen-type = true
remove-optional = true
add-optional = true
widen-container-type = true
swap-iterator-generator = true
typevar-variance = true
[typemut.ignore-types]
# qualified type patterns each operator must not touch; "all" applies to every operator
all = []
add-optional = ["sqlalchemy.orm.Mapped"]
[typemut.ignore-classes]
# qualified class patterns whose fields (and their subclasses' fields) each operator
# must not touch; "all" applies to every operator
all = []
add-optional = ["myproject.api.BaseResponse"]
[typemut.entrypoints]
# qualified name patterns of framework-called handlers whose signatures are not mutated
decorators = [] # functions decorated with these
base-classes = [] # methods of classes inheriting from these
parameter-types = [] # functions with a parameter annotated with these
HTML Report
The HTML report shows:
- Summary stats and per-module mutation scores
- Each mutant as a collapsible card with unified diff
- Color-coded status: killed (green), survived (red), error (orange)
- Full type checker output per mutant
- Expand/Collapse All controls
typemut html --open # save and open in browser
typemut html -o report.html # save to specific file
Development
make install # create venv and install with dev deps
make test # run tests
make lint # run ruff, flake8/wps, mypy
make mutate # run typemut on itself (--incremental: reuses kills in typemut.sqlite);
# fails on survivors missing from typemut-baseline.json
# extra options: make mutate MUTATE_ARGS="--refresh"
make mutate-baseline # accept the current survivors (review the diff before committing)
Dependencies
- parso — CST parsing (preserves formatting and whitespace)
- rich — terminal reporting
- click — CLI framework
Metadata
Release files for typemut 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| typemut-0.4.0.tar.gz | 197.5 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| typemut-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 312.5 kB
Release files / typemut-0.4.0.tar.gz
| Download URL | typemut-0.4.0.tar.gz |
|---|---|
| Size | 197.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
69006ce13fcf56395776ffe6cb0c65e13968fda4fb2aeb905ab33bc031394e8e
|
|
BLAKE2b-256 checksum How to use checksums |
70fda8a222a96dc44eb360c39053f1e92036d8d85ce4730703b81ca1dd423df3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logRelease files / typemut-0.4.0-py3-none-any.whl
| Download URL | typemut-0.4.0-py3-none-any.whl |
|---|---|
| Size | 115.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
55df766d6cee41e63eb0b795b32539fa2d9673ad65e3fa786aa998b1436d9a96
|
|
BLAKE2b-256 checksum How to use checksums |
541c5e6273c8e54f468012a159d3d38a114895b168add4e0a80bf1a29d60c904
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logSecurity for typemut 0.4.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for typemut-0.4.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.