diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..e235efeb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +default_language_version: + python: python3.10 +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-docstring-first + exclude: ^e2e_projects/ + - id: check-json + - id: check-merge-conflict + exclude: \.rst$ + - id: check-yaml + - id: debug-statements + exclude: tests/data/test_generation/invalid_syntax\.py + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.4 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.19.1 + hooks: + - id: mypy + args: [--config-file=mypy.ini] + exclude: (tests/|docs/) + additional_dependencies: + - click>=8.0.0 + - coverage>=7.3.0 + - libcst>=1.8.5 + - pytest>=6.2.5 + - setproctitle>=1.1.0 + - textual>=1.0.0 + - types-toml>=0.10.2 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 2d50d4d1..d6c024a1 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -25,6 +25,15 @@ We use `inline-snapshot` for E2E and integration tests, to prevent unexpected ch If pytest terminates before reporting the test failures, it likely hit a case where mutmut calls `os._exit(...)`. Try looking at these calls first for troubleshooting. +Running the tests in a container +-------------------------------- + +Tests are run in CI using a Linux container, if you are not running natively on Linux, you can run the tests in a container using the following script: + +.. code-block:: console + + ./scripts/run_tests.sh + Running your local version of Mutmut against a test codebase ------------------------------------------------------------ @@ -46,6 +55,14 @@ Codebases using Poetry # Install dependencies in your Poetry environment pip install -r /requirements.txt + +Linting and Formatting +^^^^^^^^^^^^^^^^^^^^^^ + +This project primarily uses `ruff` for linting and formatting through `pre-commit`. You can run the linting and formatting locally with `uv run pre-commit run --all-files`. + +Additionally (and recommended), you can run `pre-commit install` to install the pre-commit hooks to run automatically when running `git commit`. + Documentation about mutmut's architecture ----------------------------------------- diff --git a/cached-results-plan.txt b/cached-results-plan.txt index e70d5be2..960a3093 100644 --- a/cached-results-plan.txt +++ b/cached-results-plan.txt @@ -1,4 +1,4 @@ Mutmut cached results plan: -Unanswered question: +Unanswered question: When do we update the cache? It must be safe so that you can quit mutmut at any time and the cache won't be broken. diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test new file mode 100644 index 00000000..dee6efe2 --- /dev/null +++ b/docker/Dockerfile.test @@ -0,0 +1,14 @@ +FROM python:3.10.19-slim-trixie AS base + +WORKDIR /mutmut + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +ENV UV_PROJECT_ENVIRONMENT=/opt/venv + +COPY . . + +RUN uv sync --group dev + +ENTRYPOINT ["uv", "run", "pytest"] +CMD ["--verbose"] diff --git a/docs/_themes/flask/static/flasky.css_t b/docs/_themes/flask/static/flasky.css_t index 79ab4787..609894ec 100755 --- a/docs/_themes/flask/static/flasky.css_t +++ b/docs/_themes/flask/static/flasky.css_t @@ -8,11 +8,11 @@ {% set page_width = '940px' %} {% set sidebar_width = '220px' %} - + @import url("basic.css"); - + /* -- page layout ----------------------------------------------------------- */ - + body { font-family: 'Georgia', serif; font-size: 17px; @@ -43,7 +43,7 @@ div.sphinxsidebar { hr { border: 1px solid #B1B4B6; } - + div.body { background-color: #ffffff; color: #3E4349; @@ -54,7 +54,7 @@ img.floatingflask { padding: 0 0 10px 10px; float: right; } - + div.footer { width: {{ page_width }}; margin: 20px auto 30px auto; @@ -70,7 +70,7 @@ div.footer a { div.related { display: none; } - + div.sphinxsidebar a { color: #444; text-decoration: none; @@ -80,7 +80,7 @@ div.sphinxsidebar a { div.sphinxsidebar a:hover { border-bottom: 1px solid #999; } - + div.sphinxsidebar { font-size: 14px; line-height: 1.5; @@ -95,7 +95,7 @@ div.sphinxsidebarwrapper p.logo { margin: 0; text-align: center; } - + div.sphinxsidebar h3, div.sphinxsidebar h4 { font-family: 'Garamond', 'Georgia', serif; @@ -109,7 +109,7 @@ div.sphinxsidebar h4 { div.sphinxsidebar h4 { font-size: 20px; } - + div.sphinxsidebar h3 a { color: #444; } @@ -120,7 +120,7 @@ div.sphinxsidebar p.logo a:hover, div.sphinxsidebar h3 a:hover { border: none; } - + div.sphinxsidebar p { color: #555; margin: 10px 0; @@ -131,25 +131,25 @@ div.sphinxsidebar ul { padding: 0; color: #000; } - + div.sphinxsidebar input { border: 1px solid #ccc; font-family: 'Georgia', serif; font-size: 1em; } - + /* -- body styles ----------------------------------------------------------- */ - + a { color: #004B6B; text-decoration: underline; } - + a:hover { color: #6D4100; text-decoration: underline; } - + div.body h1, div.body h2, div.body h3, @@ -169,24 +169,24 @@ div.indexwrapper h1 { height: {{ theme_index_logo_height }}; } {% endif %} - + div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } div.body h2 { font-size: 180%; } div.body h3 { font-size: 150%; } div.body h4 { font-size: 130%; } div.body h5 { font-size: 100%; } div.body h6 { font-size: 100%; } - + a.headerlink { color: #ddd; padding: 0 4px; text-decoration: none; } - + a.headerlink:hover { color: #444; } - + div.body p, div.body dd, div.body li { line-height: 1.4em; } @@ -233,20 +233,20 @@ div.note { background-color: #eee; border: 1px solid #ccc; } - + div.seealso { background-color: #ffc; border: 1px solid #ff6; } - + div.topic { background-color: #eee; } - + p.admonition-title { display: inline; } - + p.admonition-title:after { content: ":"; } @@ -340,7 +340,7 @@ ul, ol { margin: 10px 0 10px 30px; padding: 0; } - + pre { background: #eee; padding: 7px 30px; @@ -357,7 +357,7 @@ dl dl pre { margin-left: -90px; padding-left: 90px; } - + tt { background-color: #ecf0f3; color: #222; diff --git a/docs/_themes/flask/theme.conf b/docs/_themes/flask/theme.conf index 1d5657f2..d356c176 100755 --- a/docs/_themes/flask/theme.conf +++ b/docs/_themes/flask/theme.conf @@ -4,6 +4,6 @@ stylesheet = flasky.css pygments_style = flask_theme_support.FlaskyStyle [options] -index_logo = +index_logo = index_logo_height = 120px -touch_icon = +touch_icon = diff --git a/docs/_themes/flask_theme_support.py b/docs/_themes/flask_theme_support.py index d3e33c06..93e879f4 100755 --- a/docs/_themes/flask_theme_support.py +++ b/docs/_themes/flask_theme_support.py @@ -39,8 +39,18 @@ """ # flasky extensions. flasky pygments style based on tango style from pygments.style import Style -from pygments.token import Keyword, Name, Comment, String, Error, \ - Number, Operator, Generic, Whitespace, Punctuation, Other, Literal +from pygments.token import Comment +from pygments.token import Error +from pygments.token import Generic +from pygments.token import Keyword +from pygments.token import Literal +from pygments.token import Name +from pygments.token import Number +from pygments.token import Operator +from pygments.token import Other +from pygments.token import Punctuation +from pygments.token import String +from pygments.token import Whitespace class FlaskyStyle(Style): diff --git a/e2e_projects/config/README.md b/e2e_projects/config/README.md index 1d59f03c..f2ba131e 100644 --- a/e2e_projects/config/README.md +++ b/e2e_projects/config/README.md @@ -1 +1 @@ -This project uses most/all of the mutmut configuration in pyproject.toml. \ No newline at end of file +This project uses most/all of the mutmut configuration in pyproject.toml. diff --git a/e2e_projects/config/config_pkg/ignore_me.py b/e2e_projects/config/config_pkg/ignore_me.py index 22822e70..f8946007 100644 --- a/e2e_projects/config/config_pkg/ignore_me.py +++ b/e2e_projects/config/config_pkg/ignore_me.py @@ -1,2 +1,2 @@ def this_function_shall_NOT_be_mutated(): - return 1 + 2 \ No newline at end of file + return 1 + 2 diff --git a/e2e_projects/config/data/data.json b/e2e_projects/config/data/data.json index f3e455d1..ac2ca4c1 100644 --- a/e2e_projects/config/data/data.json +++ b/e2e_projects/config/data/data.json @@ -1,3 +1,3 @@ { "comment": "this should be copied to the mutants folder" -} \ No newline at end of file +} diff --git a/e2e_projects/config/tests/main/test_main.py b/e2e_projects/config/tests/main/test_main.py index c632e4bc..15500b61 100644 --- a/e2e_projects/config/tests/main/test_main.py +++ b/e2e_projects/config/tests/main/test_main.py @@ -26,7 +26,7 @@ def test_include_data_exists(): data = json.load(f) assert data['comment'] == 'this should be copied to the mutants folder' -# ignored, because it does not match -k 'test_include' +# ignored, because it does not match -k 'test_include' def test_should_be_ignored(): assert 'This test should be ignored' == 1234 @@ -38,4 +38,4 @@ def test_include_xfail_that_does_not_fail(): # ignored, because of -m 'not fail' @pytest.mark.fail def test_include_that_should_be_ignored(): - assert 'This test should be ignored' == 1234 \ No newline at end of file + assert 'This test should be ignored' == 1234 diff --git a/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/__init__.py b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/__init__.py index dee88f62..2c0c2bde 100644 --- a/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/__init__.py +++ b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/__init__.py @@ -40,4 +40,3 @@ def mutate_only_covered_lines_multiline(simple_branch: bool) -> str: ] return f"Hello from mutate_only_covered_lines!" \ f" (false) {x} {y}" - diff --git a/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/ignore_me.py b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/ignore_me.py index 22822e70..f8946007 100644 --- a/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/ignore_me.py +++ b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/ignore_me.py @@ -1,2 +1,2 @@ def this_function_shall_NOT_be_mutated(): - return 1 + 2 \ No newline at end of file + return 1 + 2 diff --git a/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py b/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py index ca32451f..f1b135a4 100644 --- a/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py +++ b/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py @@ -13,4 +13,4 @@ def test_mutate_only_covered_lines_multiline(): assert mutate_only_covered_lines_multiline(True) == "Hello from mutate_only_covered_lines! (true) FooBar [0, 4, 8, 12, 16]" def call_ignored_function(): - assert this_function_shall_NOT_be_mutated() == 3 \ No newline at end of file + assert this_function_shall_NOT_be_mutated() == 3 diff --git a/e2e_projects/my_lib/README.md b/e2e_projects/my_lib/README.md index 6903a98b..b35eeffa 100644 --- a/e2e_projects/my_lib/README.md +++ b/e2e_projects/my_lib/README.md @@ -1 +1 @@ -This project will be E2E tested. It mainly serves as a "canary" that alerts you when code changes affect which mutants survive. \ No newline at end of file +This project will be E2E tested. It mainly serves as a "canary" that alerts you when code changes affect which mutants survive. diff --git a/e2e_projects/my_lib/src/my_lib/__init__.py b/e2e_projects/my_lib/src/my_lib/__init__.py index 8ca0c381..97278b19 100644 --- a/e2e_projects/my_lib/src/my_lib/__init__.py +++ b/e2e_projects/my_lib/src/my_lib/__init__.py @@ -110,4 +110,4 @@ def func_with_star(a, /, b, *, c, **kwargs): def func_with_arbitrary_args_clone(*args, **kwargs): pass # pragma: no mutate def func_with_arbitrary_args(*args, **kwargs): - return len(args) + len(kwargs) \ No newline at end of file + return len(args) + len(kwargs) diff --git a/e2e_projects/my_lib/tests/test_my_lib.py b/e2e_projects/my_lib/tests/test_my_lib.py index 3609cc9c..3febc7af 100644 --- a/e2e_projects/my_lib/tests/test_my_lib.py +++ b/e2e_projects/my_lib/tests/test_my_lib.py @@ -66,4 +66,3 @@ def test_signature_functions_are_callable(): def test_signature_is_coroutine(): assert asyncio.iscoroutinefunction(async_consumer) - diff --git a/e2e_projects/py3_14_features/README.md b/e2e_projects/py3_14_features/README.md index 6903a98b..b35eeffa 100644 --- a/e2e_projects/py3_14_features/README.md +++ b/e2e_projects/py3_14_features/README.md @@ -1 +1 @@ -This project will be E2E tested. It mainly serves as a "canary" that alerts you when code changes affect which mutants survive. \ No newline at end of file +This project will be E2E tested. It mainly serves as a "canary" that alerts you when code changes affect which mutants survive. diff --git a/e2e_projects/py3_14_features/src/py3_14_features/__init__.py b/e2e_projects/py3_14_features/src/py3_14_features/__init__.py index aa59bed7..50a5f4ff 100644 --- a/e2e_projects/py3_14_features/src/py3_14_features/__init__.py +++ b/e2e_projects/py3_14_features/src/py3_14_features/__init__.py @@ -12,7 +12,7 @@ def get_len(data: Collection): def get_len_clone(data: Collection): pass # pragma: no mutate -# verify that mutmut can handle annotations that area +# verify that mutmut can handle annotations that area def get_foo_len(data: Foo) -> int: return len(data.foo) + 0 diff --git a/e2e_projects/py3_14_features/tests/test_py3_14_features.py b/e2e_projects/py3_14_features/tests/test_py3_14_features.py index 51fc2288..9932620c 100644 --- a/e2e_projects/py3_14_features/tests/test_py3_14_features.py +++ b/e2e_projects/py3_14_features/tests/test_py3_14_features.py @@ -18,4 +18,3 @@ def test_annotations(): def test_signature(): # mutmut currently only achieves a stringified version, because we cannot eagerly evalute the signature assert inspect.signature(get_len, annotation_format=inspect.Format.STRING) == inspect.signature(get_len_clone, annotation_format=inspect.Format.STRING) - diff --git a/e2e_projects/type_checking/README.md b/e2e_projects/type_checking/README.md index efdcc6cf..9fc5e3f9 100644 --- a/e2e_projects/type_checking/README.md +++ b/e2e_projects/type_checking/README.md @@ -1 +1 @@ -This project uses type checking to detect invalid mutants. \ No newline at end of file +This project uses type checking to detect invalid mutants. diff --git a/e2e_projects/type_checking/pyproject.toml b/e2e_projects/type_checking/pyproject.toml index 4603161a..82d10d77 100644 --- a/e2e_projects/type_checking/pyproject.toml +++ b/e2e_projects/type_checking/pyproject.toml @@ -29,4 +29,4 @@ project-includes = [ ] [tool.pyright] -typeCheckingMode = "strict" \ No newline at end of file +typeCheckingMode = "strict" diff --git a/e2e_projects/type_checking/tests/test_type_checking.py b/e2e_projects/type_checking/tests/test_type_checking.py index ed5af874..14f0a701 100644 --- a/e2e_projects/type_checking/tests/test_type_checking.py +++ b/e2e_projects/type_checking/tests/test_type_checking.py @@ -7,4 +7,4 @@ def test_a_hello_wrapper(): assert isinstance(a_hello_wrapper(), str) def test_mutate_me(): - assert mutate_me() == "charlie" \ No newline at end of file + assert mutate_me() == "charlie" diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..5e75770b --- /dev/null +++ b/mypy.ini @@ -0,0 +1,12 @@ +[mypy] +mypy_path = src:types +strict = True +check_untyped_defs = True +explicit_package_bases = True +warn_redundant_casts = True + +[mypy-hammett] +ignore_missing_imports = True + +[mypy-e2e_projects.*] +ignore_errors = True diff --git a/pyproject.toml b/pyproject.toml index 64218ae0..2f29f048 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ source-include = ["HISTORY.rst"] [dependency-groups] dev = [ "inline-snapshot>=0.32.0", + "pre-commit>=4.5.1", "pyrefly>=0.53.0", "pytest-asyncio>=1.0.0", "ruff>=0.15.1", @@ -67,3 +68,35 @@ asyncio_default_fixture_loop_scope = "function" [tool.inline-snapshot] format-command="ruff format --stdin-filename {filename}" + +[tool.ruff] +line-length = 120 +target-version = "py310" +extend-exclude = [ + "docs/_themes/", + "docs/conf.py", + "e2e_projects/", + "tests/data/test_generation/", +] + +[tool.ruff.lint] +select = ["E", "F", "W", "B", "C4", "UP", "I"] +ignore = [ + "E501", # line too long - handled by formatter + "B007", # unused loop control variable + "B018", # useless expression + "B024", # abstract base class without abstract methods + "B027", # empty method in abstract base class without abstract decorator + "B028", # no explicit stacklevel in warnings.warn + "B904", # raise without from in except + "C405", # unnecessary list literal + "C408", # unnecessary dict() call + "C416", # unnecessary list comprehension + "F402", # import shadowed by loop variable + "F841", # local variable assigned but never used + "UP007", # use X | Y for type annotations + "UP038", # use X | Y in isinstance +] + +[tool.ruff.lint.isort] +force-single-line = true diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100755 index 00000000..1bc2086e --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +docker build -t mutmut -f ./docker/Dockerfile.test . +docker run --rm -t -v "$(pwd)":/mutmut mutmut "$@" diff --git a/src/mutmut/__init__.py b/src/mutmut/__init__.py index b330c7c4..f77effc2 100644 --- a/src/mutmut/__init__.py +++ b/src/mutmut/__init__.py @@ -1,18 +1,25 @@ +from __future__ import annotations + import importlib.metadata from collections import defaultdict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mutmut.__main__ import Config __version__ = importlib.metadata.version("mutmut") -duration_by_test = defaultdict(float) -stats_time = None -config = None +duration_by_test: defaultdict[str, float] = defaultdict(float) +stats_time: float | None = None +config: Config | None = None + +_stats: set[str] = set() +tests_by_mangled_function_name: defaultdict[str, set[str]] = defaultdict(set) +_covered_lines: dict[str, set[int]] | None = None -_stats = set() -tests_by_mangled_function_name = defaultdict(set) -_covered_lines = None -def _reset_globals(): +def _reset_globals() -> None: global duration_by_test, stats_time, config, _stats, tests_by_mangled_function_name global _covered_lines diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 2b83ca6c..2a710c87 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -1,12 +1,19 @@ -from typing import Iterable -from mutmut.type_checking import TypeCheckingError -from mutmut.type_checking import run_type_checker -from typing import Any +from __future__ import annotations + import os -import sys import platform -if platform.system() == 'Windows': - print('To run mutmut on Windows, please use the WSL. Native windows support is tracked in issue https://github.com/boxed/mutmut/issues/397') +import sys +from collections.abc import Iterable +from collections.abc import Iterator +from typing import Any + +from mutmut.type_checking import TypeCheckingError +from mutmut.type_checking import run_type_checker + +if platform.system() == "Windows": + print( + "To run mutmut on Windows, please use the WSL. Native windows support is tracked in issue https://github.com/boxed/mutmut/issues/397" + ) sys.exit(1) import ast import fnmatch @@ -14,53 +21,46 @@ import inspect import itertools import json -from multiprocessing import Pool, set_start_method, Lock import resource import shutil import signal import subprocess +import warnings from abc import ABC from collections import defaultdict -from configparser import ( - ConfigParser, - NoOptionError, - NoSectionError, -) +from configparser import ConfigParser +from configparser import NoOptionError +from configparser import NoSectionError from contextlib import contextmanager -from dataclasses import dataclass, field -from datetime import ( - datetime, - timedelta, -) +from dataclasses import dataclass +from dataclasses import field +from datetime import datetime +from datetime import timedelta from difflib import unified_diff from io import TextIOBase from json import JSONDecodeError from math import ceil -from os import ( - makedirs, - walk, -) -from os.path import ( - isdir, - isfile, -) +from multiprocessing import Lock +from multiprocessing import Pool +from multiprocessing import set_start_method +from os import makedirs +from os import walk +from os.path import isdir +from os.path import isfile from pathlib import Path from signal import SIGTERM from threading import Thread -from time import ( - process_time, - sleep, -) -import warnings +from time import process_time +from time import sleep import click import libcst as cst -import libcst.matchers as m from rich.text import Text from setproctitle import setproctitle import mutmut -from mutmut.code_coverage import gather_coverage, get_covered_lines_for_file +from mutmut.code_coverage import gather_coverage +from mutmut.code_coverage import get_covered_lines_for_file from mutmut.file_mutation import mutate_file_contents from mutmut.trampoline_templates import CLASS_NAME_SEPARATOR @@ -69,81 +69,80 @@ # TODO: pragma no mutate should end up in `skipped` category -status_by_exit_code = defaultdict(lambda: 'suspicious', { - 1: 'killed', - 3: 'killed', # internal error in pytest means a kill - -24: 'killed', - 0: 'survived', - 5: 'no tests', - 2: 'check was interrupted by user', - None: 'not checked', - 33: 'no tests', - 34: 'skipped', - 35: 'suspicious', - 36: 'timeout', - 37: 'caught by type check', - -24: 'timeout', # SIGXCPU - 24: 'timeout', # SIGXCPU - 152: 'timeout', # SIGXCPU - 255: 'timeout', - -11: 'segfault', - -9: 'segfault', -}) +status_by_exit_code = defaultdict( + lambda: "suspicious", + { + 1: "killed", + 3: "killed", # internal error in pytest means a kill + -24: "killed", + 0: "survived", + 5: "no tests", + 2: "check was interrupted by user", + None: "not checked", + 33: "no tests", + 34: "skipped", + 35: "suspicious", + 36: "timeout", + 37: "caught by type check", + -24: "timeout", # SIGXCPU + 24: "timeout", # SIGXCPU + 152: "timeout", # SIGXCPU + 255: "timeout", + -11: "segfault", + -9: "segfault", + }, +) emoji_by_status = { - 'survived': 'πŸ™', - 'no tests': 'πŸ«₯', - 'timeout': '⏰', - 'suspicious': 'πŸ€”', - 'skipped': 'πŸ”‡', - 'caught by type check': 'πŸ§™', - 'check was interrupted by user': 'πŸ›‘', - 'not checked': '?', - 'killed': 'πŸŽ‰', - 'segfault': 'πŸ’₯', -} - -exit_code_to_emoji = { - exit_code: emoji_by_status[status] - for exit_code, status in status_by_exit_code.items() + "survived": "πŸ™", + "no tests": "πŸ«₯", + "timeout": "⏰", + "suspicious": "πŸ€”", + "skipped": "πŸ”‡", + "caught by type check": "πŸ§™", + "check was interrupted by user": "πŸ›‘", + "not checked": "?", + "killed": "πŸŽ‰", + "segfault": "πŸ’₯", } +exit_code_to_emoji = {exit_code: emoji_by_status[status] for exit_code, status in status_by_exit_code.items()} -def guess_paths_to_mutate(): - """Guess the path to source code to mutate - :rtype: str - """ +def guess_paths_to_mutate() -> list[Path]: + """Guess the path to source code to mutate""" this_dir = os.getcwd().split(os.sep)[-1] - if isdir('lib'): - return ['lib'] - elif isdir('src'): - return ['src'] + if isdir("lib"): + return [Path("lib")] + elif isdir("src"): + return [Path("src")] elif isdir(this_dir): - return [this_dir] - elif isdir(this_dir.replace('-', '_')): - return [this_dir.replace('-', '_')] - elif isdir(this_dir.replace(' ', '_')): - return [this_dir.replace(' ', '_')] - elif isdir(this_dir.replace('-', '')): - return [this_dir.replace('-', '')] - elif isdir(this_dir.replace(' ', '')): - return [this_dir.replace(' ', '')] - if isfile(this_dir + '.py'): - return [this_dir + '.py'] + return [Path(this_dir)] + elif isdir(this_dir.replace("-", "_")): + return [Path(this_dir.replace("-", "_"))] + elif isdir(this_dir.replace(" ", "_")): + return [Path(this_dir.replace(" ", "_"))] + elif isdir(this_dir.replace("-", "")): + return [Path(this_dir.replace("-", ""))] + elif isdir(this_dir.replace(" ", "")): + return [Path(this_dir.replace(" ", ""))] + if isfile(this_dir + ".py"): + return [Path(this_dir + ".py")] raise FileNotFoundError( - 'Could not figure out where the code to mutate is. ' - 'Please specify it by adding "paths_to_mutate=code_dir" in setup.cfg to the [mutmut] section.') + "Could not figure out where the code to mutate is. " + 'Please specify it by adding "paths_to_mutate=code_dir" in setup.cfg to the [mutmut] section.' + ) -def record_trampoline_hit(name): - assert not name.startswith('src.'), f'Failed trampoline hit. Module name starts with `src.`, which is invalid' +def record_trampoline_hit(name: str) -> None: + assert not name.startswith("src."), "Failed trampoline hit. Module name starts with `src.`, which is invalid" + assert mutmut.config is not None if mutmut.config.max_stack_depth != -1: f = inspect.currentframe() c = mutmut.config.max_stack_depth while c and f: filename = f.f_code.co_filename - if 'pytest' in filename or 'hammett' in filename or 'unittest' in filename: + if "pytest" in filename or "hammett" in filename or "unittest" in filename: break f = f.f_back c -= 1 @@ -154,20 +153,21 @@ def record_trampoline_hit(name): mutmut._stats.add(name) -def walk_all_files(): +def walk_all_files() -> Iterable[tuple[str, str]]: + assert mutmut.config is not None for path in mutmut.config.paths_to_mutate: if not isdir(path): if isfile(path): - yield '', str(path) + yield "", str(path) continue for root, dirs, files in walk(path): for filename in files: yield root, filename -def walk_source_files(): +def walk_source_files() -> Iterable[Path]: for root, filename in walk_all_files(): - if filename.endswith('.py'): + if filename.endswith(".py"): yield Path(root) / filename @@ -181,22 +181,24 @@ class CollectTestsFailedException(Exception): class BadTestExecutionCommandsException(Exception): def __init__(self, pytest_args: list[str]) -> None: - msg = f'Failed to run pytest with args: {pytest_args}. If your config sets debug=true, the original pytest error should be above.' + msg = f"Failed to run pytest with args: {pytest_args}. If your config sets debug=true, the original pytest error should be above." super().__init__(msg) class InvalidGeneratedSyntaxException(Exception): def __init__(self, file: Path | str) -> None: - super().__init__(f'Mutmut generated invalid python syntax for {file}. ' - 'If the original file has valid python syntax, please file an issue ' - 'with a minimal reproducible example file.') + super().__init__( + f"Mutmut generated invalid python syntax for {file}. " + "If the original file has valid python syntax, please file an issue " + "with a minimal reproducible example file." + ) -def copy_src_dir(): +def copy_src_dir() -> None: for root, name in walk_all_files(): source_path = Path(root) / name - target_path = Path('mutants') / root / name - + target_path = Path("mutants") / root / name + if target_path.exists(): continue @@ -207,20 +209,24 @@ def copy_src_dir(): # copy mtime, so we later know that when source_mtime == target_mtime, the file is not (yet) mutated. shutil.copy2(source_path, target_path) + @dataclass class FileMutationResult: """Dataclass to transfer warnings and errors from child processes to the parent""" + warnings: list[Warning] = field(default_factory=list) error: Exception | None = None unmodified: bool = False ignored: bool = False + @dataclass class MutantGenerationStats: mutated: int = 0 unmodified: int = 0 ignored: int = 0 + def create_mutants(max_children: int) -> MutantGenerationStats: stats = MutantGenerationStats() with Pool(processes=max_children) as p: @@ -237,12 +243,13 @@ def create_mutants(max_children: int) -> MutantGenerationStats: stats.mutated += 1 return stats + def create_file_mutants(path: Path) -> FileMutationResult: try: print(path) - output_path = Path('mutants') / path + output_path = Path("mutants") / path makedirs(output_path.parent, exist_ok=True) - + assert mutmut.config is not None if mutmut.config.should_ignore_for_mutation(path): shutil.copy(path, output_path) return FileMutationResult(ignored=True) @@ -251,11 +258,12 @@ def create_file_mutants(path: Path) -> FileMutationResult: except Exception as e: return FileMutationResult(error=e) -def setup_source_paths(): + +def setup_source_paths() -> None: # ensure that the mutated source code can be imported by the tests - source_code_paths = [Path('.'), Path('src'), Path('source')] + source_code_paths = [Path("."), Path("src"), Path("source")] for path in source_code_paths: - mutated_path = Path('mutants') / path + mutated_path = Path("mutants") / path if mutated_path.exists(): sys.path.insert(0, str(mutated_path.absolute())) @@ -265,16 +273,20 @@ def setup_source_paths(): while i < len(sys.path) and Path(sys.path[i]).resolve() == path.resolve(): del sys.path[i] -def store_lines_covered_by_tests(): + +def store_lines_covered_by_tests() -> None: + assert mutmut.config is not None if mutmut.config.mutate_only_covered_lines: mutmut._covered_lines = gather_coverage(PytestRunner(), list(walk_source_files())) -def copy_also_copy_files(): + +def copy_also_copy_files() -> None: + assert mutmut.config is not None assert isinstance(mutmut.config.also_copy, list) for path in mutmut.config.also_copy: - print(' also copying', path) + print(" also copying", path) path = Path(path) - destination = Path('mutants') / path + destination = Path("mutants") / path if not path.exists(): continue if path.is_file(): @@ -282,6 +294,7 @@ def copy_also_copy_files(): else: shutil.copytree(path, destination, dirs_exist_ok=True) + def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationResult: warnings: list[Warning] = [] @@ -307,12 +320,12 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe with open(filename) as f: source = f.read() - with open(output_path, 'w') as out: + with open(output_path, "w") as out: try: mutant_names = write_all_mutants_to_file(out=out, source=source, filename=filename) except cst.ParserSyntaxError as e: # if libcst cannot parse it, then copy the source without any mutations - warnings.append(SyntaxWarning(f'Unsupported syntax in {filename} ({str(e)}), skipping')) + warnings.append(SyntaxWarning(f"Unsupported syntax in {filename} ({str(e)}), skipping")) out.write(source) mutant_names = [] @@ -333,52 +346,56 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe return FileMutationResult(warnings=warnings) + def get_mutant_name(relative_source_path: Path, mutant_method_name: str) -> str: - module_name = str(relative_source_path)[:-len(relative_source_path.suffix)].replace(os.sep, '.') - module_name = strip_prefix(module_name, prefix='src.') + module_name = str(relative_source_path)[: -len(relative_source_path.suffix)].replace(os.sep, ".") + module_name = strip_prefix(module_name, prefix="src.") # FYI, we currently use "mutant_name" inconsistently, for both the whole identifier including the path and only the mangled method name - mutant_name = f'{module_name}.{mutant_method_name}' - mutant_name = mutant_name.replace('.__init__.', '.') + mutant_name = f"{module_name}.{mutant_method_name}" + mutant_name = mutant_name.replace(".__init__.", ".") return mutant_name -def write_all_mutants_to_file(*, out, source, filename): - result, mutant_names = mutate_file_contents(filename, source, get_covered_lines_for_file(filename, mutmut._covered_lines)) + +def write_all_mutants_to_file(*, out: Any, source: str, filename: str | Path) -> Any: + result, mutant_names = mutate_file_contents( + str(filename), source, get_covered_lines_for_file(str(filename), mutmut._covered_lines) + ) out.write(result) return mutant_names class SourceFileMutationData: - def __init__(self, *, path): - self.estimated_time_of_tests_by_mutant = {} + def __init__(self, *, path: Path) -> None: + self.estimated_time_of_tests_by_mutant: dict[str, float] = {} self.path = path - self.meta_path = Path('mutants') / (str(path) + '.meta') - self.key_by_pid = {} - self.exit_code_by_key = {} - self.durations_by_key = {} + self.meta_path = Path("mutants") / (str(path) + ".meta") + self.key_by_pid: dict[int, str] = {} + self.exit_code_by_key: dict[str, int | None] = {} + self.durations_by_key: dict[str, float] = {} self.type_check_error_by_key: dict[str, str] = {} - self.start_time_by_pid = {} + self.start_time_by_pid: dict[int, datetime] = {} - def load(self): + def load(self) -> None: try: with open(self.meta_path) as f: meta = json.load(f) except FileNotFoundError: return - self.exit_code_by_key = meta.pop('exit_code_by_key') - self.durations_by_key = meta.pop('durations_by_key') - self.estimated_time_of_tests_by_mutant = meta.pop('estimated_durations_by_key') - self.type_check_error_by_key = meta.pop('type_check_error_by_key') - assert not meta, f'Meta file {self.meta_path} contains unexpected keys: {set(meta.keys())}' + self.exit_code_by_key = meta.pop("exit_code_by_key") + self.durations_by_key = meta.pop("durations_by_key") + self.estimated_time_of_tests_by_mutant = meta.pop("estimated_durations_by_key") + self.type_check_error_by_key = meta.pop("type_check_error_by_key") + assert not meta, f"Meta file {self.meta_path} contains unexpected keys: {set(meta.keys())}" - def register_pid(self, *, pid, key): + def register_pid(self, *, pid: int, key: str) -> None: self.key_by_pid[pid] = key with START_TIMES_BY_PID_LOCK: self.start_time_by_pid[pid] = datetime.now() - def register_result(self, *, pid, exit_code): + def register_result(self, *, pid: int, exit_code: int) -> None: assert self.key_by_pid[pid] in self.exit_code_by_key key = self.key_by_pid[pid] self.exit_code_by_key[key] = exit_code @@ -389,28 +406,34 @@ def register_result(self, *, pid, exit_code): del self.start_time_by_pid[pid] self.save() - def stop_children(self): + def stop_children(self) -> None: for pid in self.key_by_pid.keys(): os.kill(pid, SIGTERM) - def save(self): - with open(self.meta_path, 'w') as f: - json.dump(dict( - exit_code_by_key=self.exit_code_by_key, - durations_by_key=self.durations_by_key, - type_check_error_by_key=self.type_check_error_by_key, - estimated_durations_by_key=self.estimated_time_of_tests_by_mutant, - ), f, indent=4) - -def filter_mutants_with_type_checker(): - with change_cwd(Path('mutants')): + def save(self) -> None: + with open(self.meta_path, "w") as f: + json.dump( + dict( + exit_code_by_key=self.exit_code_by_key, + durations_by_key=self.durations_by_key, + type_check_error_by_key=self.type_check_error_by_key, + estimated_durations_by_key=self.estimated_time_of_tests_by_mutant, + ), + f, + indent=4, + ) + + +def filter_mutants_with_type_checker() -> dict[str, FailedTypeCheckMutant]: + assert mutmut.config is not None + with change_cwd(Path("mutants")): errors = run_type_checker(mutmut.config.type_check_command) errors_by_path = group_by_path(errors) mutants_to_skip: dict[str, FailedTypeCheckMutant] = {} for path, errors_of_file in errors_by_path.items(): - with open(path, 'r', encoding='utf-8') as file: + with open(path, encoding="utf-8") as file: source = file.read() wrapper = cst.MetadataWrapper(cst.parse_module(source)) visitor = MutatedMethodsCollector(path) @@ -419,20 +442,24 @@ def filter_mutants_with_type_checker(): for error in errors_of_file: assert error.file_path == visitor.file - mutant = next((m for m in mutated_methods if m.line_number_start <= error.line_number <= m.line_number_end), None) + mutant = next( + (m for m in mutated_methods if m.line_number_start <= error.line_number <= m.line_number_end), None + ) if mutant is None: - raise Exception(f'Could not find mutant for type error {error.file_path}:{error.line_number} ({error.error_description}). ' - 'Probably, a code mutation influenced types in unexpected locations. ' - 'If your project normally has no type errors and uses mypy/pyrefly, please file an issue with steps to reproduce on github.') + raise Exception( + f"Could not find mutant for type error {error.file_path}:{error.line_number} ({error.error_description}). " + "Probably, a code mutation influenced types in unexpected locations. " + "If your project normally has no type errors and uses mypy/pyrefly, please file an issue with steps to reproduce on github." + ) - mutant_name = get_mutant_name(path.relative_to(Path('.').absolute()), mutant.function_name) + mutant_name = get_mutant_name(path.relative_to(Path(".").absolute()), mutant.function_name) mutants_to_skip[mutant_name] = FailedTypeCheckMutant( method_location=mutant, name=mutant_name, error=error, ) - + return mutants_to_skip @@ -444,6 +471,7 @@ def group_by_path(errors: list[TypeCheckingError]) -> dict[Path, list[TypeChecki return grouped + @dataclass class MutatedMethodLocation: file: Path @@ -461,7 +489,7 @@ class FailedTypeCheckMutant: class MutatedMethodsCollector(cst.CSTVisitor): METADATA_DEPENDENCIES = (cst.metadata.PositionProvider,) - + def __init__(self, file: Path): self.file = file self.found_mutants: list[MutatedMethodLocation] = [] @@ -470,51 +498,54 @@ def visit_FunctionDef(self, node: cst.FunctionDef) -> bool: name = node.name.value if is_mutated_method_name(name): range = self.get_metadata(cst.metadata.PositionProvider, node) - self.found_mutants.append(MutatedMethodLocation( - file=self.file, - function_name=name, - line_number_start=range.start.line, - line_number_end=range.end.line, - )) + self.found_mutants.append( + MutatedMethodLocation( + file=self.file, + function_name=name, + line_number_start=range.start.line, + line_number_end=range.end.line, + ) + ) # do not continue visting children of this function # mutated methods are not nested within other methods return False -def is_mutated_method_name(name: str): - return name.startswith(('x_', 'xǁ')) and '__mutmut' in name + +def is_mutated_method_name(name: str) -> bool: + return name.startswith(("x_", "xǁ")) and "__mutmut" in name -def unused(*_): +def unused(*_: Any) -> None: pass -def strip_prefix(s, *, prefix, strict=False): +def strip_prefix(s: str, *, prefix: str, strict: bool = False) -> str: if s.startswith(prefix): - return s[len(prefix):] + return s[len(prefix) :] assert strict is False, f"String '{s}' does not start with prefix '{prefix}'" return s class TestRunner(ABC): - def run_stats(self, *, tests): + def run_stats(self, *, tests: Iterable[str]) -> int: raise NotImplementedError() - def run_forced_fail(self): + def run_forced_fail(self) -> int: raise NotImplementedError() - def prepare_main_test_run(self): + def prepare_main_test_run(self) -> None: pass - def run_tests(self, *, mutant_name, tests): + def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: raise NotImplementedError() - def list_all_tests(self): + def list_all_tests(self) -> ListAllTestsResult: raise NotImplementedError() @contextmanager -def change_cwd(path): +def change_cwd(path: str | Path) -> Iterator[None]: old_cwd = os.path.abspath(os.getcwd()) os.chdir(path) try: @@ -523,32 +554,36 @@ def change_cwd(path): os.chdir(old_cwd) -def collected_test_names(): +def collected_test_names() -> set[str]: return set(mutmut.duration_by_test.keys()) class ListAllTestsResult: - def __init__(self, *, ids): + def __init__(self, *, ids: set[str]) -> None: assert isinstance(ids, set) self.ids = ids - def clear_out_obsolete_test_names(self): + def clear_out_obsolete_test_names(self) -> None: count_before = sum(len(x) for x in mutmut.tests_by_mangled_function_name) - mutmut.tests_by_mangled_function_name = defaultdict(set, **{ - k: {test_name for test_name in test_names if test_name in self.ids} - for k, test_names in mutmut.tests_by_mangled_function_name.items() - }) + mutmut.tests_by_mangled_function_name = defaultdict( + set, + **{ + k: {test_name for test_name in test_names if test_name in self.ids} + for k, test_names in mutmut.tests_by_mangled_function_name.items() + }, + ) count_after = sum(len(x) for x in mutmut.tests_by_mangled_function_name) if count_before != count_after: - print(f'Removed {count_before - count_after} obsolete test names') + print(f"Removed {count_before - count_after} obsolete test names") save_stats() - def new_tests(self): + def new_tests(self) -> set[str]: return self.ids - collected_test_names() class PytestRunner(TestRunner): - def __init__(self): + def __init__(self) -> None: + assert mutmut.config is not None self._pytest_add_cli_args: list[str] = mutmut.config.pytest_add_cli_args self._pytest_add_cli_args_test_selection: list[str] = mutmut.config.pytest_add_cli_args_test_selection @@ -556,80 +591,82 @@ def __init__(self): # so also use pytest_add_cli_args_test_selection for the implementation self._pytest_add_cli_args_test_selection += mutmut.config.tests_dir - # noinspection PyMethodMayBeStatic - def execute_pytest(self, params: list[str], **kwargs): + def execute_pytest(self, params: list[str], **kwargs: Any) -> int: import pytest - params = ['--rootdir=.', '--tb=native'] + params + self._pytest_add_cli_args + + params = ["--rootdir=.", "--tb=native"] + params + self._pytest_add_cli_args + assert mutmut.config is not None if mutmut.config.debug: - params = ['-vv'] + params - print('python -m pytest ', ' '.join([f'"{param}"' for param in params])) + params = ["-vv"] + params + print("python -m pytest ", " ".join([f'"{param}"' for param in params])) exit_code = int(pytest.main(params, **kwargs)) if mutmut.config.debug: - print(' exit code', exit_code) + print(" exit code", exit_code) if exit_code == 4: raise BadTestExecutionCommandsException(params) return exit_code - def run_stats(self, *, tests): + def run_stats(self, *, tests: Iterable[str]) -> int: class StatsCollector: # noinspection PyMethodMayBeStatic - def pytest_runtest_logstart(self, nodeid, location): + def pytest_runtest_logstart(self, nodeid: str, location: Any) -> None: mutmut.duration_by_test[nodeid] = 0 # noinspection PyMethodMayBeStatic - def pytest_runtest_teardown(self, item, nextitem): + def pytest_runtest_teardown(self, item: Any, nextitem: Any) -> None: unused(nextitem) for function in mutmut._stats: - mutmut.tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix='mutants/')) + mutmut.tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix="mutants/")) mutmut._stats.clear() # noinspection PyMethodMayBeStatic - def pytest_runtest_makereport(self, item, call): + def pytest_runtest_makereport(self, item: Any, call: Any) -> None: mutmut.duration_by_test[item.nodeid] += call.duration stats_collector = StatsCollector() - pytest_args = ['-x', '-q'] + pytest_args = ["-x", "-q"] if tests: pytest_args += list(tests) else: pytest_args += self._pytest_add_cli_args_test_selection - with change_cwd('mutants'): + with change_cwd("mutants"): return int(self.execute_pytest(pytest_args, plugins=[stats_collector])) - def run_tests(self, *, mutant_name, tests): - pytest_args = ['-x', '-q', '-p', 'no:randomly', '-p', 'no:random-order'] + def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: + pytest_args = ["-x", "-q", "-p", "no:randomly", "-p", "no:random-order"] if tests: pytest_args += list(tests) else: pytest_args += self._pytest_add_cli_args_test_selection - with change_cwd('mutants'): + with change_cwd("mutants"): return int(self.execute_pytest(pytest_args)) - def run_forced_fail(self): - pytest_args = ['-x', '-q'] + self._pytest_add_cli_args_test_selection - with change_cwd('mutants'): + def run_forced_fail(self) -> int: + pytest_args = ["-x", "-q"] + self._pytest_add_cli_args_test_selection + with change_cwd("mutants"): return int(self.execute_pytest(pytest_args)) - def list_all_tests(self): + def list_all_tests(self) -> ListAllTestsResult: class TestsCollector: - def __init__(self): - self.collected_nodeids = set() - self.deselected_nodeids = set() + def __init__(self) -> None: + self.collected_nodeids: set[str] = set() + self.deselected_nodeids: set[str] = set() - def pytest_collection_modifyitems(self, items): + def pytest_collection_modifyitems(self, items: Any) -> None: self.collected_nodeids |= {item.nodeid for item in items} - def pytest_deselected(self, items): + def pytest_deselected(self, items: Any) -> None: self.deselected_nodeids |= {item.nodeid for item in items} collector = TestsCollector() - tests_dir = mutmut.config.tests_dir - pytest_args = ['-x', '-q', '--collect-only'] + self._pytest_add_cli_args_test_selection + assert mutmut.config is not None + tests_dir = mutmut.config.tests_dir # noqa: F841 + pytest_args = ["-x", "-q", "--collect-only"] + self._pytest_add_cli_args_test_selection - with change_cwd('mutants'): + with change_cwd("mutants"): exit_code = int(self.execute_pytest(pytest_args, plugins=[collector])) if exit_code != 0: raise CollectTestsFailedException() @@ -639,26 +676,40 @@ def pytest_deselected(self, items): class HammettRunner(TestRunner): - def __init__(self): - self.hammett_kwargs = None + def __init__(self) -> None: + self.hammett_kwargs: Any = None - def run_stats(self, *, tests): + def run_stats(self, *, tests: Iterable[str]) -> int: import hammett - print('Running hammett stats...') - def post_test_callback(_name, **_): + print("Running hammett stats...") + + def post_test_callback(_name: str, **_: Any) -> None: for function in mutmut._stats: mutmut.tests_by_mangled_function_name[function].add(_name) mutmut._stats.clear() - return hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, post_test_callback=post_test_callback, use_cache=False, insert_cwd=False) + return int( + hammett.main( + quiet=True, + fail_fast=True, + disable_assert_analyze=True, + post_test_callback=post_test_callback, + use_cache=False, + insert_cwd=False, + ) + ) - def run_forced_fail(self): + def run_forced_fail(self) -> int: import hammett - return hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, use_cache=False, insert_cwd=False) - def prepare_main_test_run(self): + return int( + hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, use_cache=False, insert_cwd=False) + ) + + def prepare_main_test_run(self) -> None: import hammett + self.hammett_kwargs = hammett.main_setup( quiet=True, fail_fast=True, @@ -667,34 +718,35 @@ def prepare_main_test_run(self): insert_cwd=False, ) - def run_tests(self, *, mutant_name, tests): + def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: import hammett - hammett.Config.workerinput = dict(workerinput=f'_{mutant_name}') - return hammett.main_run_tests(**self.hammett_kwargs, tests=tests) + hammett.Config.workerinput = dict(workerinput=f"_{mutant_name}") + return int(hammett.main_run_tests(**self.hammett_kwargs, tests=tests)) -def mangled_name_from_mutant_name(mutant_name): - assert '__mutmut_' in mutant_name, mutant_name - return mutant_name.partition('__mutmut_')[0] +def mangled_name_from_mutant_name(mutant_name: str) -> str: + assert "__mutmut_" in mutant_name, mutant_name + return mutant_name.partition("__mutmut_")[0] -def orig_function_and_class_names_from_key(mutant_name): + +def orig_function_and_class_names_from_key(mutant_name: str) -> tuple[str, str | None]: r = mangled_name_from_mutant_name(mutant_name) - _, _, r = r.rpartition('.') + _, _, r = r.rpartition(".") class_name = None if CLASS_NAME_SEPARATOR in r: - class_name = r[r.index(CLASS_NAME_SEPARATOR) + 1: r.rindex(CLASS_NAME_SEPARATOR)] - r = r[r.rindex(CLASS_NAME_SEPARATOR) + 1:] + class_name = r[r.index(CLASS_NAME_SEPARATOR) + 1 : r.rindex(CLASS_NAME_SEPARATOR)] + r = r[r.rindex(CLASS_NAME_SEPARATOR) + 1 :] else: - assert r.startswith('x_'), r + assert r.startswith("x_"), r r = r[2:] return r, class_name -spinner = itertools.cycle('⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏') +spinner = itertools.cycle("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") -def status_printer(): +def status_printer() -> Any: """Manage the printing and in-place updating of a line of characters .. note:: @@ -705,15 +757,17 @@ def status_printer(): last_update = [datetime(1900, 1, 1)] update_threshold = timedelta(seconds=0.1) - def p(s, *, force_output=False): + def p(s: str, *, force_output: bool = False) -> None: if not force_output and (datetime.now() - last_update[0]) < update_threshold: return - s = next(spinner) + ' ' + s + s = next(spinner) + " " + s len_s = len(s) - output = '\r' + s + (' ' * max(last_len[0] - len_s, 0)) + output = "\r" + s + (" " * max(last_len[0] - len_s, 0)) + assert sys.__stdout__ is not None sys.__stdout__.write(output) sys.__stdout__.flush() last_len[0] = len_s + return p @@ -735,21 +789,18 @@ class Stat: caught_by_type_check: int -def collect_stat(m: SourceFileMutationData): - r = { - k.replace(' ', '_'): 0 - for k in status_by_exit_code.values() - } +def collect_stat(m: SourceFileMutationData) -> Stat: + r = {k.replace(" ", "_"): 0 for k in status_by_exit_code.values()} for k, v in m.exit_code_by_key.items(): # noinspection PyTypeChecker - r[status_by_exit_code[v].replace(' ', '_')] += 1 + r[status_by_exit_code[v].replace(" ", "_")] += 1 return Stat( **r, total=sum(r.values()), ) -def calculate_summary_stats(source_file_mutation_data_by_path): +def calculate_summary_stats(source_file_mutation_data_by_path: dict[str, SourceFileMutationData]) -> Stat: stats = [collect_stat(x) for x in source_file_mutation_data_by_path.values()] return Stat( not_checked=sum(x.not_checked for x in stats), @@ -766,14 +817,19 @@ def calculate_summary_stats(source_file_mutation_data_by_path): ) -def print_stats(source_file_mutation_data_by_path, force_output=False): +def print_stats( + source_file_mutation_data_by_path: dict[str, SourceFileMutationData], force_output: bool = False +) -> None: s = calculate_summary_stats(source_file_mutation_data_by_path) - print_status(f'{(s.total - s.not_checked)}/{s.total} πŸŽ‰ {s.killed} πŸ«₯ {s.no_tests} ⏰ {s.timeout} πŸ€” {s.suspicious} πŸ™ {s.survived} πŸ”‡ {s.skipped} πŸ§™ {s.caught_by_type_check}', force_output=force_output) + print_status( + f"{(s.total - s.not_checked)}/{s.total} πŸŽ‰ {s.killed} πŸ«₯ {s.no_tests} ⏰ {s.timeout} πŸ€” {s.suspicious} πŸ™ {s.survived} πŸ”‡ {s.skipped} πŸ§™ {s.caught_by_type_check}", + force_output=force_output, + ) -def run_forced_fail_test(runner): - os.environ['MUTANT_UNDER_TEST'] = 'fail' - with CatchOutput(spinner_title='Running forced fail test') as catcher: +def run_forced_fail_test(runner: TestRunner) -> None: + os.environ["MUTANT_UNDER_TEST"] = "fail" + with CatchOutput(spinner_title="Running forced fail test") as catcher: try: if runner.run_forced_fail() == 0: catcher.dump_output() @@ -781,53 +837,55 @@ def run_forced_fail_test(runner): raise SystemExit(1) except MutmutProgrammaticFailException: pass - os.environ['MUTANT_UNDER_TEST'] = '' - print(' done') + os.environ["MUTANT_UNDER_TEST"] = "" + print(" done") class CatchOutput: - def __init__(self, callback=lambda s: None, spinner_title=None): - self.strings = [] - self.spinner_title = spinner_title or '' + def __init__(self, callback: Any = lambda s: None, spinner_title: str | None = None) -> None: + self.strings: list[str] = [] + self.spinner_title = spinner_title or "" if mutmut.config is not None and mutmut.config.debug: - self.spinner_title += '\n' + self.spinner_title += "\n" class StdOutRedirect(TextIOBase): - def __init__(self, catcher): + def __init__(self, catcher: CatchOutput) -> None: self.catcher = catcher - def write(self, s): + def write(self, s: str) -> int: callback(s) if spinner_title: print_status(spinner_title) self.catcher.strings.append(s) return len(s) + self.redirect = StdOutRedirect(self) # noinspection PyMethodMayBeStatic - def stop(self): + def stop(self) -> None: sys.stdout = sys.__stdout__ sys.stderr = sys.__stderr__ - def start(self): + def start(self) -> None: if self.spinner_title: print_status(self.spinner_title) sys.stdout = self.redirect sys.stderr = self.redirect + assert mutmut.config is not None if mutmut.config.debug: self.stop() - def dump_output(self): + def dump_output(self) -> None: self.stop() print() for line in self.strings: - print(line, end='') + print(line, end="") - def __enter__(self): + def __enter__(self) -> CatchOutput: self.start() return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.stop() if self.spinner_title: print() @@ -846,140 +904,142 @@ class Config: mutate_only_covered_lines: bool type_check_command: list[str] - def should_ignore_for_mutation(self, path): - if not str(path).endswith('.py'): + def should_ignore_for_mutation(self, path: Path | str) -> bool: + if not str(path).endswith(".py"): return True for p in self.do_not_mutate: - if fnmatch.fnmatch(path, p): + if fnmatch.fnmatch(str(path), p): return True return False -def config_reader(): - path = Path('pyproject.toml') +def config_reader() -> Any: + path = Path("pyproject.toml") if path.exists(): if sys.version_info >= (3, 11): from tomllib import loads else: # noinspection PyPackageRequirements from toml import loads - data = loads(path.read_text('utf-8')) + data = loads(path.read_text("utf-8")) try: - config = data['tool']['mutmut'] + config = data["tool"]["mutmut"] except KeyError: pass else: - def s(key, default): + + def _toml_reader(key: str, default: Any) -> Any: try: result = config[key] except KeyError: return default return result - return s + + return _toml_reader config_parser = ConfigParser() - config_parser.read('setup.cfg') + config_parser.read("setup.cfg") - def s(key: str, default) -> Any: + def _cfg_reader(key: str, default: Any) -> Any: try: - result = config_parser.get('mutmut', key) + result: Any = config_parser.get("mutmut", key) except (NoOptionError, NoSectionError): return default if isinstance(default, list): - if '\n' in result: + if "\n" in result: result = [x for x in result.split("\n") if x] else: result = [result] elif isinstance(default, bool): - result = result.lower() in ('1', 't', 'true') + result = result.lower() in ("1", "t", "true") elif isinstance(default, int): result = int(result) return result - return s + return _cfg_reader -def ensure_config_loaded(): + +def ensure_config_loaded() -> None: if mutmut.config is None: mutmut.config = load_config() -def load_config(): +def load_config() -> Config: s = config_reader() return Config( - do_not_mutate=s('do_not_mutate', []), - also_copy=[ - Path(y) - for y in s('also_copy', []) - ] + [ - Path('tests/'), - Path('test/'), - Path('setup.cfg'), - Path('pyproject.toml'), - Path('pytest.ini'), - Path('.gitignore'), - ] + list(Path('.').glob('test*.py')), - max_stack_depth=s('max_stack_depth', -1), - debug=s('debug', False), - mutate_only_covered_lines=s('mutate_only_covered_lines', False), - paths_to_mutate=[ - Path(y) - for y in s('paths_to_mutate', []) - ] or guess_paths_to_mutate(), - tests_dir=s('tests_dir', []), - pytest_add_cli_args=s('pytest_add_cli_args', []), - pytest_add_cli_args_test_selection=s('pytest_add_cli_args_test_selection', []), - type_check_command=s('type_check_command', []), + do_not_mutate=s("do_not_mutate", []), + also_copy=[Path(y) for y in s("also_copy", [])] + + [ + Path("tests/"), + Path("test/"), + Path("setup.cfg"), + Path("pyproject.toml"), + Path("pytest.ini"), + Path(".gitignore"), + ] + + list(Path(".").glob("test*.py")), + max_stack_depth=s("max_stack_depth", -1), + debug=s("debug", False), + mutate_only_covered_lines=s("mutate_only_covered_lines", False), + paths_to_mutate=[Path(y) for y in s("paths_to_mutate", [])] or guess_paths_to_mutate(), + tests_dir=s("tests_dir", []), + pytest_add_cli_args=s("pytest_add_cli_args", []), + pytest_add_cli_args_test_selection=s("pytest_add_cli_args_test_selection", []), + type_check_command=s("type_check_command", []), ) - @click.group() @click.version_option() -def cli(): +def cli() -> None: pass -def run_stats_collection(runner, tests=None): +def run_stats_collection(runner: TestRunner, tests: Iterable[str] | None = None) -> None: if tests is None: tests = [] # Meaning all... - os.environ['MUTANT_UNDER_TEST'] = 'stats' - os.environ['PY_IGNORE_IMPORTMISMATCH'] = '1' + os.environ["MUTANT_UNDER_TEST"] = "stats" + os.environ["PY_IGNORE_IMPORTMISMATCH"] = "1" start_cpu_time = process_time() - with CatchOutput(spinner_title='Running stats') as output_catcher: + with CatchOutput(spinner_title="Running stats") as output_catcher: collect_stats_exit_code = runner.run_stats(tests=tests) if collect_stats_exit_code != 0: output_catcher.dump_output() - print(f'failed to collect stats. runner returned {collect_stats_exit_code}') + print(f"failed to collect stats. runner returned {collect_stats_exit_code}") exit(1) - # ensure that at least one mutant has associated tests num_associated_tests = sum(len(tests) for tests in mutmut.tests_by_mangled_function_name.values()) if num_associated_tests == 0: output_catcher.dump_output() - print('Stopping early, because we could not find any test case for any mutant. It seems that the selected tests do not cover any code that we mutated.') + print( + "Stopping early, because we could not find any test case for any mutant. It seems that the selected tests do not cover any code that we mutated." + ) + assert mutmut.config is not None if not mutmut.config.debug: - print('You can set debug=true to see the executed test names in the output above.') + print("You can set debug=true to see the executed test names in the output above.") else: - print('In the last pytest run above, you can see which tests we executed.') - print('You can use mutmut browse to check which parts of the source code we mutated.') - print('If some of the mutated code should be covered by the executed tests, consider opening an issue (with a MRE if possible).') + print("In the last pytest run above, you can see which tests we executed.") + print("You can use mutmut browse to check which parts of the source code we mutated.") + print( + "If some of the mutated code should be covered by the executed tests, consider opening an issue (with a MRE if possible)." + ) exit(1) - print(' done') + print(" done") if not tests: # again, meaning all mutmut.stats_time = process_time() - start_cpu_time if not collected_test_names(): - print('failed to collect stats, no active tests found') + print("failed to collect stats, no active tests found") exit(1) save_stats() -def collect_or_load_stats(runner): +def collect_or_load_stats(runner: TestRunner) -> None: did_load = load_stats() if not did_load: @@ -987,13 +1047,13 @@ def collect_or_load_stats(runner): run_stats_collection(runner) else: # Run incremental stats - with CatchOutput(spinner_title='Listing all tests') as output_catcher: - os.environ['MUTANT_UNDER_TEST'] = 'list_all_tests' + with CatchOutput(spinner_title="Listing all tests") as output_catcher: + os.environ["MUTANT_UNDER_TEST"] = "list_all_tests" try: all_tests_result = runner.list_all_tests() except CollectTestsFailedException: output_catcher.dump_output() - print('Failed to collect list of tests') + print("Failed to collect list of tests") exit(1) all_tests_result.clear_out_obsolete_test_names() @@ -1001,19 +1061,19 @@ def collect_or_load_stats(runner): new_tests = all_tests_result.new_tests() if new_tests: - print(f'Found {len(new_tests)} new tests, rerunning stats collection') + print(f"Found {len(new_tests)} new tests, rerunning stats collection") run_stats_collection(runner, tests=new_tests) -def load_stats(): +def load_stats() -> bool: did_load = False try: - with open('mutants/mutmut-stats.json') as f: + with open("mutants/mutmut-stats.json") as f: data = json.load(f) - for k, v in data.pop('tests_by_mangled_function_name').items(): + for k, v in data.pop("tests_by_mangled_function_name").items(): mutmut.tests_by_mangled_function_name[k] |= set(v) - mutmut.duration_by_test = data.pop('duration_by_test') - mutmut.stats_time = data.pop('stats_time') + mutmut.duration_by_test = data.pop("duration_by_test") + mutmut.stats_time = data.pop("stats_time") assert not data, data did_load = True except (FileNotFoundError, JSONDecodeError): @@ -1021,33 +1081,44 @@ def load_stats(): return did_load -def save_stats(): - with open('mutants/mutmut-stats.json', 'w') as f: - json.dump(dict( - tests_by_mangled_function_name={k: list(v) for k, v in mutmut.tests_by_mangled_function_name.items()}, - duration_by_test=mutmut.duration_by_test, - stats_time=mutmut.stats_time, - ), f, indent=4) +def save_stats() -> None: + with open("mutants/mutmut-stats.json", "w") as f: + json.dump( + dict( + tests_by_mangled_function_name={k: list(v) for k, v in mutmut.tests_by_mangled_function_name.items()}, + duration_by_test=mutmut.duration_by_test, + stats_time=mutmut.stats_time, + ), + f, + indent=4, + ) + -def save_cicd_stats(source_file_mutation_data_by_path): +def save_cicd_stats(source_file_mutation_data_by_path: dict[str, SourceFileMutationData]) -> None: s = calculate_summary_stats(source_file_mutation_data_by_path) - with open('mutants/mutmut-cicd-stats.json', 'w') as f: - json.dump(dict( - killed=s.killed, - survived=s.survived, - total=s.total, - no_tests=s.no_tests, - skipped=s.skipped, - suspicious=s.suspicious, - timeout=s.timeout, - check_was_interrupted_by_user=s.check_was_interrupted_by_user, - segfault=s.segfault - ), f, indent=4) + with open("mutants/mutmut-cicd-stats.json", "w") as f: + json.dump( + dict( + killed=s.killed, + survived=s.survived, + total=s.total, + no_tests=s.no_tests, + skipped=s.skipped, + suspicious=s.suspicious, + timeout=s.timeout, + check_was_interrupted_by_user=s.check_was_interrupted_by_user, + segfault=s.segfault, + ), + f, + indent=4, + ) + # exports CI/CD stats to block pull requests from merging if mutation score is too low, or used in other ways in CI/CD pipelines @cli.command() -def export_cicd_stats(): +def export_cicd_stats() -> None: ensure_config_loaded() + assert mutmut.config is not None source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {} @@ -1055,7 +1126,7 @@ def export_cicd_stats(): if mutmut.config.should_ignore_for_mutation(path): continue - meta_path = Path('mutants') / (str(path) + '.meta') + meta_path = Path("mutants") / (str(path) + ".meta") if not meta_path.exists(): continue @@ -1071,16 +1142,19 @@ def export_cicd_stats(): return save_cicd_stats(source_file_mutation_data_by_path) - print('Saved CI/CD stats to mutants/mutmut-cicd-stats.json') + print("Saved CI/CD stats to mutants/mutmut-cicd-stats.json") -def collect_source_file_mutation_data(*, mutant_names): +def collect_source_file_mutation_data( + *, mutant_names: tuple[str, ...] | list[str] +) -> tuple[list[tuple[SourceFileMutationData, str, int | None]], dict[str, SourceFileMutationData]]: + assert mutmut.config is not None source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {} for path in walk_source_files(): if mutmut.config.should_ignore_for_mutation(path): continue - assert path not in source_file_mutation_data_by_path + assert str(path) not in source_file_mutation_data_by_path m = SourceFileMutationData(path=path) m.load() source_file_mutation_data_by_path[str(path)] = m @@ -1097,19 +1171,19 @@ def collect_source_file_mutation_data(*, mutant_names): for m, key, result in mutants if key in mutant_names or any(fnmatch.fnmatch(key, mutant_name) for mutant_name in mutant_names) ] - assert filtered_mutants, f'Filtered for specific mutants, but nothing matches\n\nFilter: {mutant_names}' + assert filtered_mutants, f"Filtered for specific mutants, but nothing matches\n\nFilter: {mutant_names}" mutants = filtered_mutants return mutants, source_file_mutation_data_by_path -def estimated_worst_case_time(mutant_name): +def estimated_worst_case_time(mutant_name: str) -> float: tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) return sum(mutmut.duration_by_test[t] for t in tests) @cli.command() -@click.argument('mutant_names', required=False, nargs=-1) -def print_time_estimates(mutant_names): +@click.argument("mutant_names", required=False, nargs=-1) +def print_time_estimates(mutant_names: tuple[str, ...]) -> None: assert isinstance(mutant_names, (tuple, list)), mutant_names ensure_config_loaded() @@ -1120,23 +1194,20 @@ def print_time_estimates(mutant_names): mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names) - times_and_keys = [ - (estimated_worst_case_time(mutant_name), mutant_name) - for m, mutant_name, result in mutants - ] + times_and_keys = [(estimated_worst_case_time(mutant_name), mutant_name) for m, mutant_name, result in mutants] for time, key in sorted(times_and_keys): if not time: - print(f'', key) + print("", key) else: - print(f'{int(time*1000)}ms', key) + print(f"{int(time * 1000)}ms", key) @cli.command() -@click.argument('mutant_name', required=True, nargs=1) -def tests_for_mutant(mutant_name): +@click.argument("mutant_name", required=True, nargs=1) +def tests_for_mutant(mutant_name: str) -> None: if not load_stats(): - print('Failed to load stats. Please run mutmut first to collect stats.') + print("Failed to load stats. Please run mutmut first to collect stats.") exit(1) tests = tests_for_mutant_names([mutant_name]) @@ -1144,16 +1215,18 @@ def tests_for_mutant(mutant_name): print(test) -def stop_all_children(mutants): +def stop_all_children(mutants: list[tuple[SourceFileMutationData, str, int | None]]) -> None: for m, _, _ in mutants: m.stop_children() + # used to copy the global mutmut.config to subprocesses -set_start_method('fork') +set_start_method("fork") START_TIMES_BY_PID_LOCK = Lock() -def timeout_checker(mutants): - def inner_timeout_checker(): + +def timeout_checker(mutants: list[tuple[SourceFileMutationData, str, int | None]]) -> Any: + def inner_timeout_checker() -> None: while True: sleep(1) @@ -1169,40 +1242,45 @@ def inner_timeout_checker(): os.kill(pid, signal.SIGXCPU) except ProcessLookupError: pass + return inner_timeout_checker @cli.command() -@click.option('--max-children', type=int) -@click.argument('mutant_names', required=False, nargs=-1) -def run(mutant_names, *, max_children): +@click.option("--max-children", type=int) +@click.argument("mutant_names", required=False, nargs=-1) +def run(mutant_names: tuple[str, ...], *, max_children: int | None) -> None: assert isinstance(mutant_names, (tuple, list)), mutant_names _run(mutant_names, max_children) + # separate function, so we can call it directly from the tests -def _run(mutant_names: tuple | list, max_children: None | int): +def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> None: # TODO: run no-ops once in a while to detect if we get false negatives # TODO: we should be able to get information on which tests killed mutants, which means we can get a list of tests and how many mutants each test kills. Those that kill zero mutants are redundant! - os.environ['MUTANT_UNDER_TEST'] = 'mutant_generation' + os.environ["MUTANT_UNDER_TEST"] = "mutant_generation" ensure_config_loaded() + assert mutmut.config is not None if max_children is None: max_children = os.cpu_count() or 4 start = datetime.now() - makedirs(Path('mutants'), exist_ok=True) - with CatchOutput(spinner_title='Generating mutants'): + makedirs(Path("mutants"), exist_ok=True) + with CatchOutput(spinner_title="Generating mutants"): copy_src_dir() copy_also_copy_files() setup_source_paths() - store_lines_covered_by_tests() + store_lines_covered_by_tests() stats = create_mutants(max_children) time = datetime.now() - start - print(f' done in {round(time.total_seconds()*1000)}ms ({stats.mutated} files mutated, {stats.ignored} ignored, {stats.unmodified} unmodified)', ) + print( + f" done in {round(time.total_seconds() * 1000)}ms ({stats.mutated} files mutated, {stats.ignored} ignored, {stats.unmodified} unmodified)", + ) if mutmut.config.type_check_command: - with CatchOutput(spinner_title='Filtering mutations with type checker'): + with CatchOutput(spinner_title="Filtering mutations with type checker"): mutants_caught_by_type_checker = filter_mutants_with_type_checker() else: mutants_caught_by_type_checker = {} @@ -1218,27 +1296,28 @@ def _run(mutant_names: tuple | list, max_children: None | int): mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names) - os.environ['MUTANT_UNDER_TEST'] = '' - with CatchOutput(spinner_title='Running clean tests') as output_catcher: + os.environ["MUTANT_UNDER_TEST"] = "" + with CatchOutput(spinner_title="Running clean tests") as output_catcher: tests = tests_for_mutant_names(mutant_names) clean_test_exit_code = runner.run_tests(mutant_name=None, tests=tests) if clean_test_exit_code != 0: output_catcher.dump_output() - print('Failed to run clean test') + print("Failed to run clean test") exit(1) - print(' done') + print(" done") # this can't be the first thing, because it can fail deep inside pytest/django setup and then everything is destroyed run_forced_fail_test(runner) runner.prepare_main_test_run() - def read_one_child_exit_status(): + def read_one_child_exit_status() -> None: pid, wait_status = os.wait() exit_code = os.waitstatus_to_exitcode(wait_status) + assert mutmut.config is not None if mutmut.config.debug: - print(' worker exit code', exit_code) + print(" worker exit code", exit_code) source_file_mutation_data_by_pid[pid].register_result(pid=pid, exit_code=exit_code) source_file_mutation_data_by_pid: dict[int, SourceFileMutationData] = {} # many pids map to one MutationData @@ -1252,12 +1331,12 @@ def read_one_child_exit_status(): start = datetime.now() try: - print('Running mutation testing') + print("Running mutation testing") # Calculate times of tests for m, mutant_name, result in mutants: - mutant_name = mutant_name.replace('__init__.', '') - tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), []) + mutant_name = mutant_name.replace("__init__.", "") + tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) estimated_time_of_tests = sum(mutmut.duration_by_test[test_name] for test_name in tests) m.estimated_time_of_tests_by_mutant[mutant_name] = estimated_time_of_tests @@ -1267,13 +1346,13 @@ def read_one_child_exit_status(): for m, mutant_name, result in mutants: print_stats(source_file_mutation_data_by_path) - mutant_name = mutant_name.replace('__init__.', '') + mutant_name = mutant_name.replace("__init__.", "") # Rerun mutant if it's explicitly mentioned, but otherwise let the result stand if not mutant_names and result is not None: continue - tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), []) + tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) if not tests: m.exit_code_by_key[mutant_name] = 33 @@ -1290,12 +1369,12 @@ def read_one_child_exit_status(): pid = os.fork() if not pid: # In the child - os.environ['MUTANT_UNDER_TEST'] = mutant_name - setproctitle(f'mutmut: {mutant_name}') + os.environ["MUTANT_UNDER_TEST"] = mutant_name + setproctitle(f"mutmut: {mutant_name}") # Run fast tests first - tests = sorted(tests, key=lambda test_name: mutmut.duration_by_test[test_name]) - if not tests: + sorted_tests = sorted(tests, key=lambda test_name: mutmut.duration_by_test[test_name]) + if not sorted_tests: os._exit(33) estimated_time_of_tests = m.estimated_time_of_tests_by_mutant[mutant_name] @@ -1304,12 +1383,11 @@ def read_one_child_exit_status(): resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit, cpu_time_limit + 1)) with CatchOutput(): - result = runner.run_tests(mutant_name=mutant_name, tests=tests) + test_result = runner.run_tests(mutant_name=mutant_name, tests=sorted_tests) - if result != 0: - # TODO: write failure information to stdout? + if test_result != 0: pass - os._exit(result) + os._exit(test_result) else: # in the parent source_file_mutation_data_by_pid[pid] = m @@ -1329,34 +1407,34 @@ def read_one_child_exit_status(): except ChildProcessError: pass except KeyboardInterrupt: - print('Stopping...') + print("Stopping...") stop_all_children(mutants) t = datetime.now() - start print_stats(source_file_mutation_data_by_path, force_output=True) print() - print(f'{count_tried / t.total_seconds():.2f} mutations/second') + print(f"{count_tried / t.total_seconds():.2f} mutations/second") if mutant_names: print() - print('Mutant results') - print('--------------') + print("Mutant results") + print("--------------") exit_code_by_key = {} # If the user gave a specific list of mutants, print result for these specifically for m, mutant_name, result in mutants: exit_code_by_key[mutant_name] = m.exit_code_by_key[mutant_name] for mutant_name, exit_code in sorted(exit_code_by_key.items()): - print(emoji_by_status.get(status_by_exit_code[exit_code], '?'), mutant_name) + print(emoji_by_status.get(status_by_exit_code[exit_code], "?"), mutant_name) print() -def tests_for_mutant_names(mutant_names): +def tests_for_mutant_names(mutant_names: tuple[str, ...] | list[str]) -> set[str]: tests = set() for mutant_name in mutant_names: - if '*' in mutant_name: + if "*" in mutant_name: for name, tests_of_this_name in mutmut.tests_by_mangled_function_name.items(): if fnmatch.fnmatch(name, mutant_name): tests |= set(tests_of_this_name) @@ -1366,33 +1444,33 @@ def tests_for_mutant_names(mutant_names): @cli.command() -@click.option('--all', default=False) -def results(all): +@click.option("--all", default=False) +def results(all: bool) -> None: ensure_config_loaded() for path in walk_source_files(): - if not str(path).endswith('.py'): + if not str(path).endswith(".py"): continue m = SourceFileMutationData(path=path) m.load() for k, v in m.exit_code_by_key.items(): status = status_by_exit_code[v] - if status == 'killed' and not all: + if status == "killed" and not all: continue - print(f' {k}: {status}') + print(f" {k}: {status}") -def read_mutants_module(path) -> cst.Module: - with open(Path('mutants') / path) as f: +def read_mutants_module(path: Path | str) -> cst.Module: + with open(Path("mutants") / path) as f: return cst.parse_module(f.read()) -def read_orig_module(path) -> cst.Module: +def read_orig_module(path: Path | str) -> cst.Module: with open(path) as f: return cst.parse_module(f.read()) def find_top_level_function_or_method(module: cst.Module, name: str) -> cst.FunctionDef | None: - name = name.split('.')[-1] + name = name.split(".")[-1] for child in module.body: if isinstance(child, cst.FunctionDef) and child.name.value == name: return child @@ -1404,26 +1482,27 @@ def find_top_level_function_or_method(module: cst.Module, name: str) -> cst.Func return None -def read_original_function(module: cst.Module, mutant_name: str): +def read_original_function(module: cst.Module, mutant_name: str) -> cst.FunctionDef: orig_function_name, _ = orig_function_and_class_names_from_key(mutant_name) - orig_name = mangled_name_from_mutant_name(mutant_name) + '__mutmut_orig' + orig_name = mangled_name_from_mutant_name(mutant_name) + "__mutmut_orig" result = find_top_level_function_or_method(module, orig_name) if not result: raise FileNotFoundError(f'Could not find original function "{orig_function_name}"') - return result.with_changes(name = cst.Name(orig_function_name)) + return result.with_changes(name=cst.Name(orig_function_name)) -def read_mutant_function(module: cst.Module, mutant_name: str): +def read_mutant_function(module: cst.Module, mutant_name: str) -> cst.FunctionDef: orig_function_name, _ = orig_function_and_class_names_from_key(mutant_name) result = find_top_level_function_or_method(module, mutant_name) if not result: raise FileNotFoundError(f'Could not find original function "{orig_function_name}"') - return result.with_changes(name = cst.Name(orig_function_name)) + return result.with_changes(name=cst.Name(orig_function_name)) -def find_mutant(mutant_name): +def find_mutant(mutant_name: str) -> SourceFileMutationData: + assert mutmut.config is not None for path in walk_source_files(): if mutmut.config.should_ignore_for_mutation(path): continue @@ -1433,18 +1512,18 @@ def find_mutant(mutant_name): if mutant_name in m.exit_code_by_key: return m - raise FileNotFoundError(f'Could not find mutant {mutant_name}') + raise FileNotFoundError(f"Could not find mutant {mutant_name}") -def get_diff_for_mutant(mutant_name, source=None, path=None): +def get_diff_for_mutant(mutant_name: str, source: str | None = None, path: Path | None = None) -> str: if path is None: m = find_mutant(mutant_name) path = m.path status = status_by_exit_code[m.exit_code_by_key[mutant_name]] else: - status = 'not checked' + status = "not checked" - print(f'# {mutant_name}: {status}') + print(f"# {mutant_name}: {status}") if source is None: module = read_mutants_module(path) @@ -1453,24 +1532,28 @@ def get_diff_for_mutant(mutant_name, source=None, path=None): orig_code = cst.Module([read_original_function(module, mutant_name)]).code.strip() mutant_code = cst.Module([read_mutant_function(module, mutant_name)]).code.strip() - path = str(path) # difflib requires str, not Path - return '\n'.join([ - line - for line in unified_diff(orig_code.split('\n'), mutant_code.split('\n'), fromfile=path, tofile=path, lineterm='') - ]) + path_str = str(path) + return "\n".join( + [ + line + for line in unified_diff( + orig_code.split("\n"), mutant_code.split("\n"), fromfile=path_str, tofile=path_str, lineterm="" + ) + ] + ) @cli.command() -@click.argument('mutant_name') -def show(mutant_name): +@click.argument("mutant_name") +def show(mutant_name: str) -> None: ensure_config_loaded() print(get_diff_for_mutant(mutant_name)) return @cli.command() -@click.argument('mutant_name') -def apply(mutant_name): +@click.argument("mutant_name") +def apply(mutant_name: str) -> None: # try: ensure_config_loaded() apply_mutant(mutant_name) @@ -1478,11 +1561,11 @@ def apply(mutant_name): # print(e) -def apply_mutant(mutant_name): +def apply_mutant(mutant_name: str) -> None: path = find_mutant(mutant_name).path orig_function_name, class_name = orig_function_and_class_names_from_key(mutant_name) - orig_function_name = orig_function_name.rpartition('.')[-1] + orig_function_name = orig_function_name.rpartition(".")[-1] orig_module = read_orig_module(path) mutants_module = read_mutants_module(path) @@ -1492,29 +1575,29 @@ def apply_mutant(mutant_name): original_function = find_top_level_function_or_method(orig_module, orig_function_name) if not original_function: - raise FileNotFoundError(f'Could not apply mutant {mutant_name}') + raise FileNotFoundError(f"Could not apply mutant {mutant_name}") - new_module: cst.Module = orig_module.deep_replace(original_function, mutant_function) # type: ignore + new_module: cst.Module = orig_module.deep_replace(original_function, mutant_function) # type: ignore[arg-type] - with open(path, 'w') as f: + with open(path, "w") as f: f.write(new_module.code) @cli.command() @click.option("--show-killed", is_flag=True, default=False, help="Display mutants killed by tests and type checker.") -def browse(show_killed): +def browse(show_killed: bool) -> None: ensure_config_loaded() + from rich.syntax import Syntax from textual.app import App from textual.containers import Container - from textual.widgets import Footer + from textual.widget import Widget from textual.widgets import DataTable + from textual.widgets import Footer from textual.widgets import Static - from textual.widget import Widget - from rich.syntax import Syntax - class ResultBrowser(App): - loading_id = None + class ResultBrowser(App): # type: ignore[type-arg] + loading_id: str | None = None CSS_PATH = "result_browser_layout.tcss" BINDINGS = [ ("q", "quit()", "Quit"), @@ -1526,45 +1609,41 @@ class ResultBrowser(App): ] columns = [ - ('path', 'Path'), - ] + [ - (status, Text(emoji, justify='right')) - for status, emoji in emoji_by_status.items() - ] + ("path", "Path"), + ] + [(status, Text(emoji, justify="right")) for status, emoji in emoji_by_status.items()] - cursor_type = 'row' + cursor_type = "row" source_file_mutation_data_and_stat_by_path: dict[str, tuple[SourceFileMutationData, Stat]] = {} - def compose(self): - with Container(classes='container'): - yield DataTable(id='files') - yield DataTable(id='mutants') + def compose(self) -> Iterable[Any]: + with Container(classes="container"): + yield DataTable(id="files") + yield DataTable(id="mutants") with Widget(id="diff_view_widget"): - yield Static(id='description') - yield Static(id='diff_view') + yield Static(id="description") + yield Static(id="diff_view") yield Footer() - def on_mount(self): - # files table + def on_mount(self) -> None: # noinspection PyTypeChecker - files_table: DataTable = self.query_one('#files') - files_table.cursor_type = 'row' + files_table: DataTable[Any] = self.query_one("#files") # type: ignore[assignment] + files_table.cursor_type = "row" for key, label in self.columns: files_table.add_column(key=key, label=label) - # mutants table # noinspection PyTypeChecker - mutants_table: DataTable = self.query_one('#mutants') - mutants_table.cursor_type = 'row' - mutants_table.add_columns('name', 'status') + mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] + mutants_table.cursor_type = "row" + mutants_table.add_columns("name", "status") self.read_data() self.populate_files_table() - def read_data(self): + def read_data(self) -> None: ensure_config_loaded() + assert mutmut.config is not None self.source_file_mutation_data_and_stat_by_path = {} - self.path_by_name = {} + self.path_by_name: dict[str, Path] = {} for p in walk_source_files(): if mutmut.config.should_ignore_for_mutation(p): @@ -1577,38 +1656,37 @@ def read_data(self): for name in source_file_mutation_data.exit_code_by_key: self.path_by_name[name] = p - def populate_files_table(self): + def populate_files_table(self) -> None: # noinspection PyTypeChecker - files_table: DataTable = self.query_one('#files') + files_table: DataTable[Any] = self.query_one("#files") # type: ignore[assignment] # TODO: restore selection selected_row = files_table.cursor_row files_table.clear() for p, (source_file_mutation_data, stat) in sorted(self.source_file_mutation_data_and_stat_by_path.items()): row = [p] + [ - Text(str(getattr(stat, k.replace(' ', '_'))), justify="right") - for k, _ in self.columns[1:] + Text(str(getattr(stat, k.replace(" ", "_"))), justify="right") for k, _ in self.columns[1:] ] files_table.add_row(*row, key=str(p)) files_table.move_cursor(row=selected_row) - def on_data_table_row_highlighted(self, event): + def on_data_table_row_highlighted(self, event: Any) -> None: if not event.row_key or not event.row_key.value: return - if event.data_table.id == 'files': + if event.data_table.id == "files": # noinspection PyTypeChecker - mutants_table: DataTable = self.query_one('#mutants') + mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] mutants_table.clear() source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value] for k, v in source_file_mutation_data.exit_code_by_key.items(): status = status_by_exit_code[v] - if status not in ('killed', 'caught by type check') or show_killed: + if status not in ("killed", "caught by type check") or show_killed: mutants_table.add_row(k, emoji_by_status[status], key=k) else: - assert event.data_table.id == 'mutants' + assert event.data_table.id == "mutants" # noinspection PyTypeChecker - description_view: Static = self.query_one('#description') + description_view: Static = self.query_one("#description") # type: ignore[assignment] mutant_name = event.row_key.value self.loading_id = mutant_name path = self.path_by_name.get(mutant_name) @@ -1616,42 +1694,48 @@ def on_data_table_row_highlighted(self, event): exit_code = source_file_mutation_data.exit_code_by_key[mutant_name] status = status_by_exit_code[exit_code] - estimated_duration = source_file_mutation_data.estimated_time_of_tests_by_mutant.get(mutant_name, '?') - duration = source_file_mutation_data.durations_by_key.get(mutant_name, '?') - type_check_error = source_file_mutation_data.type_check_error_by_key.get(mutant_name, '?') + estimated_duration = source_file_mutation_data.estimated_time_of_tests_by_mutant.get(mutant_name, "?") + duration = source_file_mutation_data.durations_by_key.get(mutant_name, "?") + type_check_error = source_file_mutation_data.type_check_error_by_key.get(mutant_name, "?") - view_tests_description = f'(press t to view tests executed for this mutant)' + view_tests_description = "(press t to view tests executed for this mutant)" match status: - case 'killed': - description = f'Killed ({exit_code=}): Mutant caused a test to fail πŸŽ‰' - case 'survived': - description = f'Survived ({exit_code=}): No test detected this mutant. {view_tests_description}' - case 'skipped': - description = f'Skipped ({exit_code=})' - case 'check was interrupted by user': - description = f'User interrupted ({exit_code=})' - case 'caught by type check': - description = f'Caught by type checker ({exit_code=}): {type_check_error}' - case 'timeout': - description = (f'Timeout ({exit_code=}): Timed out because tests did not finish within {duration:.3f} seconds. ' - f'Tests without mutation took {estimated_duration:.3f} seconds. {view_tests_description}') - case 'no tests': - description = f'Untested ({exit_code=}): Skipped because selected tests do not execute this code.' - case 'segfault': - description = f'Segfault ({exit_code=}): Running pytest with this mutant segfaulted.' - case 'suspicious': - description = f'Unknown ({exit_code=}): Running pytest with this mutant resulted in an unknown exit code.' - case 'not checked': - description = 'Not checked in the last mutmut run.' + case "killed": + description = f"Killed ({exit_code=}): Mutant caused a test to fail πŸŽ‰" + case "survived": + description = f"Survived ({exit_code=}): No test detected this mutant. {view_tests_description}" + case "skipped": + description = f"Skipped ({exit_code=})" + case "check was interrupted by user": + description = f"User interrupted ({exit_code=})" + case "caught by type check": + description = f"Caught by type checker ({exit_code=}): {type_check_error}" + case "timeout": + description = ( + f"Timeout ({exit_code=}): Timed out because tests did not finish within {duration:.3f} seconds. " + f"Tests without mutation took {estimated_duration:.3f} seconds. {view_tests_description}" + ) + case "no tests": + description = ( + f"Untested ({exit_code=}): Skipped because selected tests do not execute this code." + ) + case "segfault": + description = f"Segfault ({exit_code=}): Running pytest with this mutant segfaulted." + case "suspicious": + description = ( + f"Unknown ({exit_code=}): Running pytest with this mutant resulted in an unknown exit code." + ) + case "not checked": + description = "Not checked in the last mutmut run." case _: - description = f'Unknown status ({exit_code=}, {status=})' - description_view.update(f'\n {description}\n') + description = f"Unknown status ({exit_code=}, {status=})" + description_view.update(f"\n {description}\n") - diff_view: Static = self.query_one('#diff_view') - diff_view.update('') + diff_view: Static = self.query_one("#diff_view") # type: ignore[assignment] + diff_view.update("") - def load_thread(): + def load_thread() -> None: ensure_config_loaded() try: d = get_diff_for_mutant(event.row_key.value, path=path) @@ -1663,54 +1747,62 @@ def load_thread(): t = Thread(target=load_thread) t.start() - def retest(self, pattern): - self._run_subprocess_command('run', [pattern]) + def retest(self, pattern: str) -> None: + self._run_subprocess_command("run", [pattern]) - def view_tests(self, mutant_name: str): - self._run_subprocess_command('tests-for-mutant', [mutant_name]) + def view_tests(self, mutant_name: str) -> None: + self._run_subprocess_command("tests-for-mutant", [mutant_name]) - def _run_subprocess_command(self, command: str, args: list[str]): + def _run_subprocess_command(self, command: str, args: list[str]) -> None: with self.suspend(): - browse_index = sys.argv.index('browse') + browse_index = sys.argv.index("browse") initial_args = sys.argv[:browse_index] subprocess_args = [sys.executable, *initial_args, command, *args] - print('>', *subprocess_args) + print(">", *subprocess_args) subprocess.run(subprocess_args) - input('press enter to return to browser') + input("press enter to return to browser") self.read_data() self.populate_files_table() - def get_mutant_name_from_selection(self): + def get_mutant_name_from_selection(self) -> str | None: # noinspection PyTypeChecker - mutants_table: DataTable = self.query_one('#mutants') + mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] if mutants_table.cursor_row is None: return - return mutants_table.get_row_at(mutants_table.cursor_row)[0] + return str(mutants_table.get_row_at(mutants_table.cursor_row)[0]) - def action_retest_mutant(self): - self.retest(self.get_mutant_name_from_selection()) + def action_retest_mutant(self) -> None: + name = self.get_mutant_name_from_selection() + if name is not None: + self.retest(name) - def action_retest_function(self): - self.retest(self.get_mutant_name_from_selection().rpartition('__mutmut_')[0] + '__mutmut_*') + def action_retest_function(self) -> None: + name = self.get_mutant_name_from_selection() + if name is not None: + self.retest(name.rpartition("__mutmut_")[0] + "__mutmut_*") - def action_retest_module(self): - self.retest(self.get_mutant_name_from_selection().rpartition('.')[0] + '.*') + def action_retest_module(self) -> None: + name = self.get_mutant_name_from_selection() + if name is not None: + self.retest(name.rpartition(".")[0] + ".*") - def action_apply_mutant(self): + def action_apply_mutant(self) -> None: ensure_config_loaded() # noinspection PyTypeChecker - mutants_table: DataTable = self.query_one('#mutants') + mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] if mutants_table.cursor_row is None: return apply_mutant(mutants_table.get_row_at(mutants_table.cursor_row)[0]) - def action_view_tests(self): - self.view_tests(self.get_mutant_name_from_selection()) + def action_view_tests(self) -> None: + name = self.get_mutant_name_from_selection() + if name is not None: + self.view_tests(name) ResultBrowser().run() -if __name__ == '__main__': +if __name__ == "__main__": cli() diff --git a/src/mutmut/code_coverage.py b/src/mutmut/code_coverage.py index 65ebbda0..d8d69fbf 100644 --- a/src/mutmut/code_coverage.py +++ b/src/mutmut/code_coverage.py @@ -1,45 +1,56 @@ -import coverage +from __future__ import annotations + import importlib import sys +from collections.abc import Iterable from pathlib import Path +from types import ModuleType +from typing import TYPE_CHECKING + +import coverage +from coverage import CoverageData + +if TYPE_CHECKING: + from mutmut.__main__ import TestRunner # Returns a set of lines that are covered in this file gvein the covered_lines dict # returned by gather_coverage # None means it's not enabled, set() means no lines are covered -def get_covered_lines_for_file(filename: str, covered_lines: dict[str, set[int]]): +def get_covered_lines_for_file(filename: str, covered_lines: dict[str, set[int]] | None) -> set[int] | None: if covered_lines is None or filename is None: return None - abs_filename = str((Path('mutants') / filename).absolute()) + abs_filename = str((Path("mutants") / filename).absolute()) lines = None if abs_filename in covered_lines: lines = covered_lines[abs_filename] - return lines or set() + return lines or set() + # Gathers coverage for the given source files and # Returns a dict of filenames to sets of lines that are covered -# Since this is run on the source files before we create mutations, +# Since this is run on the source files before we create mutations, # we need to unload any modules that get loaded during the test run -def gather_coverage(runner, source_files): +def gather_coverage(runner: TestRunner, source_files: Iterable[Path]) -> dict[str, set[int]]: # We want to unload any python modules that get loaded # because we plan to mutate them and want them to be reloaded modules = dict(sys.modules) - mutants_path = Path('mutants') - + mutants_path = Path("mutants") + # Run the tests and gather coverage cov = coverage.Coverage(source=[str(mutants_path.absolute())], data_file=None) with cov.collect(): runner.prepare_main_test_run() - runner.run_tests(mutant_name=None, tests=None) + runner.run_tests(mutant_name=None, tests=[]) # Build mapping of filenames to covered lines # The CoverageData object is a wrapper around sqlite, and this # will make it more efficient to access the data - covered_lines = {} - coverage_data = cov.get_data() + covered_lines: dict[str, set[int]] = {} + coverage_data: CoverageData = cov.get_data() for filename in source_files: abs_filename = str((mutants_path / filename).absolute()) @@ -47,17 +58,18 @@ def gather_coverage(runner, source_files): if lines is None: # file was not imported during test run, e.g. because test selection excluded this file lines = [] - covered_lines[abs_filename] = list(lines) + covered_lines[abs_filename] = set(lines) _unload_modules_not_in(modules) return covered_lines + # Unloads modules that are not in the 'modules' list -def _unload_modules_not_in(modules): - for name in list(sys.modules): - if name == 'mutmut.code_coverage': +def _unload_modules_not_in(modules: dict[str, ModuleType]) -> None: + for name in list(sys.modules): + if name == "mutmut.code_coverage": continue if name not in modules: sys.modules.pop(name, None) - importlib.invalidate_caches() \ No newline at end of file + importlib.invalidate_caches() diff --git a/src/mutmut/file_mutation.py b/src/mutmut/file_mutation.py index 374096e3..e3217282 100644 --- a/src/mutmut/file_mutation.py +++ b/src/mutmut/file_mutation.py @@ -1,26 +1,35 @@ """This module contains code for managing mutant creation for whole files.""" from collections import defaultdict -from collections.abc import Iterable, Sequence, Mapping +from collections.abc import Iterable +from collections.abc import Mapping +from collections.abc import Sequence from dataclasses import dataclass from typing import Union + import libcst as cst -from libcst.metadata import PositionProvider, MetadataWrapper import libcst.matchers as m -from mutmut.trampoline_templates import create_trampoline_lookup, mangle_function_name, trampoline_impl -from mutmut.node_mutation import mutation_operators, OPERATORS_TYPE +from libcst.metadata import MetadataWrapper +from libcst.metadata import PositionProvider + +from mutmut.node_mutation import OPERATORS_TYPE +from mutmut.node_mutation import mutation_operators +from mutmut.trampoline_templates import create_trampoline_lookup +from mutmut.trampoline_templates import mangle_function_name +from mutmut.trampoline_templates import trampoline_impl + +NEVER_MUTATE_FUNCTION_NAMES = {"__getattribute__", "__setattr__", "__new__"} +NEVER_MUTATE_FUNCTION_CALLS = {"len", "isinstance"} -NEVER_MUTATE_FUNCTION_NAMES = { "__getattribute__", "__setattr__", "__new__"} -NEVER_MUTATE_FUNCTION_CALLS = { "len", "isinstance" } @dataclass class Mutation: original_node: cst.CSTNode mutated_node: cst.CSTNode - contained_by_top_level_function: Union[cst.FunctionDef, None] + contained_by_top_level_function: cst.CSTNode | None -def mutate_file_contents(filename: str, code: str, covered_lines: Union[set[int], None] = None) -> tuple[str, Sequence[str]]: +def mutate_file_contents(filename: str, code: str, covered_lines: set[int] | None = None) -> tuple[str, Sequence[str]]: """Create mutations for `code` and merge them to a single mutated file with trampolines. :return: A tuple of (mutated code, list of mutant function names)""" @@ -28,10 +37,8 @@ def mutate_file_contents(filename: str, code: str, covered_lines: Union[set[int] return combine_mutations_to_source(module, mutations) -def create_mutations( - code: str, - covered_lines: Union[set[int], None] = None -) -> tuple[cst.Module, list[Mutation]]: + +def create_mutations(code: str, covered_lines: set[int] | None = None) -> tuple[cst.Module, list[Mutation]]: """Parse the code and create mutations.""" ignored_lines = pragma_no_mutate_lines(code) @@ -43,7 +50,8 @@ def create_mutations( return module, visitor.mutations -class OuterFunctionProvider(cst.BatchableMetadataProvider): + +class OuterFunctionProvider(cst.BatchableMetadataProvider[cst.CSTNode | None]): """Link all nodes to the top-level function or method that contains them. For instance given this module: @@ -53,13 +61,14 @@ def foo(): def bar(): x = 1 ``` - + Then `self.get_metadata(OuterFunctionProvider, )` returns ``. """ - def __init__(self): + + def __init__(self) -> None: super().__init__() - def visit_Module(self, node: cst.Module): + def visit_Module(self, node: cst.Module) -> bool | None: for child in node.body: if isinstance(child, cst.FunctionDef): # mark all nodes inside the function to belong to this function @@ -75,12 +84,13 @@ def visit_Module(self, node: cst.Module): class OuterFunctionVisitor(cst.CSTVisitor): """Mark all nodes as children of `top_level_node`.""" + def __init__(self, provider: "OuterFunctionProvider", top_level_node: cst.CSTNode) -> None: self.provider = provider self.top_level_node = top_level_node super().__init__() - def on_visit(self, node: cst.CSTNode): + def on_visit(self, node: cst.CSTNode) -> bool: self.provider.set_metadata(node, self.top_level_node) return True @@ -88,18 +98,18 @@ def on_visit(self, node: cst.CSTNode): class MutationVisitor(cst.CSTVisitor): """Iterate through all nodes in the module and create mutations for them. Ignore nodes at lines `ignore_lines` and several other cases (e.g. nodes within type annotations). - + The created mutations will be accessible at `self.mutations`.""" METADATA_DEPENDENCIES = (PositionProvider, OuterFunctionProvider) - def __init__(self, operators: OPERATORS_TYPE, ignore_lines: set[int], covered_lines: Union[set[int], None] = None): + def __init__(self, operators: OPERATORS_TYPE, ignore_lines: set[int], covered_lines: set[int] | None = None): self.mutations: list[Mutation] = [] self._operators = operators self._ignored_lines = ignore_lines self._covered_lines = covered_lines - def on_visit(self, node): + def on_visit(self, node: cst.CSTNode) -> bool: if self._skip_node_and_children(node): return False @@ -109,46 +119,53 @@ def on_visit(self, node): # continue to mutate children return True - def _create_mutations(self, node: cst.CSTNode): + def _create_mutations(self, node: cst.CSTNode) -> None: for t, operator in self._operators: if isinstance(node, t): for mutated_node in operator(node): mutation = Mutation( original_node=node, mutated_node=mutated_node, - contained_by_top_level_function=self.get_metadata(OuterFunctionProvider, node, None), # type: ignore + contained_by_top_level_function=self.get_metadata(OuterFunctionProvider, node, None), ) self.mutations.append(mutation) - def _should_mutate_node(self, node: cst.CSTNode): + def _should_mutate_node(self, node: cst.CSTNode) -> bool: # currently, the position metadata does not always exist # (see https://github.com/Instagram/LibCST/issues/1322) - position = self.get_metadata(PositionProvider,node, None) + position = self.get_metadata(PositionProvider, node, None) if position: # do not mutate nodes with a pragma: no mutate comment if position.start.line in self._ignored_lines: return False # do not mutate nodes that are not covered - if self._covered_lines is not None and not position.start.line in self._covered_lines: + if self._covered_lines is not None and position.start.line not in self._covered_lines: return False return True - def _skip_node_and_children(self, node: cst.CSTNode): - if (isinstance(node, cst.Call) and isinstance(node.func, cst.Name) and node.func.value in NEVER_MUTATE_FUNCTION_CALLS) \ - or (isinstance(node, cst.FunctionDef) and node.name.value in NEVER_MUTATE_FUNCTION_NAMES): + def _skip_node_and_children(self, node: cst.CSTNode) -> bool: + if ( + isinstance(node, cst.Call) + and isinstance(node.func, cst.Name) + and node.func.value in NEVER_MUTATE_FUNCTION_CALLS + ) or (isinstance(node, cst.FunctionDef) and node.name.value in NEVER_MUTATE_FUNCTION_NAMES): return True # ignore everything inside of type annotations if isinstance(node, cst.Annotation): return True - # default args are executed at definition time + # default args are executed at definition time # We want to prevent e.g. def foo(x = abs(-1)) mutating to def foo(x = abs(None)), # which would raise an Exception as soon as the function is defined (can break the whole import) # Therefore we only allow simple default values, where mutations should not raise exceptions - if isinstance(node, cst.Param) and node.default and not isinstance(node.default, (cst.Name, cst.BaseNumber, cst.BaseString)): + if ( + isinstance(node, cst.Param) + and node.default + and not isinstance(node.default, (cst.Name, cst.BaseNumber, cst.BaseString)) + ): return True # ignore decorated functions, because @@ -161,17 +178,16 @@ def _skip_node_and_children(self, node: cst.CSTNode): return False - MODULE_STATEMENT = Union[cst.SimpleStatementLine, cst.BaseCompoundStatement] # convert str trampoline implementations to CST nodes with some whitespace trampoline_impl_cst = list(cst.parse_module(trampoline_impl).body) -trampoline_impl_cst[-1] = trampoline_impl_cst[-1].with_changes(leading_lines = [cst.EmptyLine(), cst.EmptyLine()]) +trampoline_impl_cst[-1] = trampoline_impl_cst[-1].with_changes(leading_lines=[cst.EmptyLine(), cst.EmptyLine()]) def combine_mutations_to_source(module: cst.Module, mutations: Sequence[Mutation]) -> tuple[str, Sequence[str]]: """Create mutated functions and trampolines for all mutations and compile them to a single source code. - + :param module: The original parsed module :param mutations: Mutations that should be applied. :return: Mutated code and list of mutation names""" @@ -181,7 +197,7 @@ def combine_mutations_to_source(module: cst.Module, mutations: Sequence[Mutation mutation_names: list[str] = [] # statements we still need to potentially mutate and add to the result - remaining_statements = module.body[len(result):] + remaining_statements = module.body[len(result) :] # trampoline functions result.extend(trampoline_impl_cst) @@ -213,7 +229,9 @@ def combine_mutations_to_source(module: cst.Module, mutations: Sequence[Mutation if not isinstance(method, cst.FunctionDef) or not method_mutants: mutated_body.append(method) continue - nodes, mutant_names = function_trampoline_arrangement(method, method_mutants, class_name=cls.name.value) + nodes, mutant_names = function_trampoline_arrangement( + method, method_mutants, class_name=cls.name.value + ) mutated_body.extend(nodes) mutation_names.extend(mutant_names) @@ -224,32 +242,37 @@ def combine_mutations_to_source(module: cst.Module, mutations: Sequence[Mutation mutated_module = module.with_changes(body=result) return mutated_module.code, mutation_names -def function_trampoline_arrangement(function: cst.FunctionDef, mutants: Iterable[Mutation], class_name: Union[str, None]) -> tuple[Sequence[MODULE_STATEMENT], Sequence[str]]: + +def function_trampoline_arrangement( + function: cst.FunctionDef, mutants: Iterable[Mutation], class_name: str | None +) -> tuple[Sequence[MODULE_STATEMENT], Sequence[str]]: """Create mutated functions and a trampoline that switches between original and mutated versions. - + :return: A tuple of (nodes, mutant names)""" nodes: list[MODULE_STATEMENT] = [] mutant_names: list[str] = [] name = function.name.value - mangled_name = mangle_function_name(name=name, class_name=class_name) + '__mutmut' + mangled_name = mangle_function_name(name=name, class_name=class_name) + "__mutmut" # trampoline with same signature, that forwards the calls to the activated mutant/original method # (put first, s.t. it stays next to @overload definitions of this function. mypy needs this) nodes.append(create_trampoline_wrapper(function, mangled_name, class_name)) # copy of original function - nodes.append(function.with_changes(name=cst.Name(mangled_name + '_orig'))) + nodes.append(function.with_changes(name=cst.Name(mangled_name + "_orig"))) # mutated versions of the function for i, mutant in enumerate(mutants): - mutant_name = f'{mangled_name}_{i+1}' + mutant_name = f"{mangled_name}_{i + 1}" mutant_names.append(mutant_name) - mutated_method = function.with_changes(name=cst.Name(mutant_name)) - mutated_method = deep_replace(mutated_method, mutant.original_node, mutant.mutated_node) - nodes.append(mutated_method) # type: ignore + mutated_method_base = function.with_changes(name=cst.Name(mutant_name)) + mutated_method_result = deep_replace(mutated_method_base, mutant.original_node, mutant.mutated_node) + nodes.append(mutated_method_result) # type: ignore[arg-type] - mutants_dict = list(cst.parse_module(create_trampoline_lookup(orig_name=name, mutants=mutant_names, class_name=class_name)).body) + mutants_dict = list( + cst.parse_module(create_trampoline_lookup(orig_name=name, mutants=mutant_names, class_name=class_name)).body + ) mutants_dict[0] = mutants_dict[0].with_changes(leading_lines=[cst.EmptyLine()]) nodes.extend(mutants_dict) @@ -270,15 +293,15 @@ def create_trampoline_wrapper(function: cst.FunctionDef, mangled_name: str, clas # remove self arg (handled by the trampoline function) args = args[1:] - args_assignemnt = cst.Assign([cst.AssignTarget(cst.Name(value='args'))], cst.List(args)) + args_assignemnt = cst.Assign([cst.AssignTarget(cst.Name(value="args"))], cst.List(args)) kwargs: list[cst.DictElement | cst.StarredDictElement] = [] for param in function.params.kwonly_params: kwargs.append(cst.DictElement(cst.SimpleString(f"'{param.name.value}'"), param.name)) if isinstance(function.params.star_kwarg, cst.Param): kwargs.append(cst.StarredDictElement(function.params.star_kwarg.name)) - - kwargs_assignment = cst.Assign([cst.AssignTarget(cst.Name(value='kwargs'))], cst.Dict(kwargs)) + + kwargs_assignment = cst.Assign([cst.AssignTarget(cst.Name(value="kwargs"))], cst.Dict(kwargs)) def _get_local_name(func_name: str) -> cst.BaseExpression: # for top level, simply return the name @@ -286,18 +309,18 @@ def _get_local_name(func_name: str) -> cst.BaseExpression: return cst.Name(func_name) # for class methods, use object.__getattribute__(self, name) return cst.Call( - func=cst.Attribute(cst.Name('object'), cst.Name('__getattribute__')), - args=[cst.Arg(cst.Name('self')), cst.Arg(cst.SimpleString(f"'{func_name}'"))] + func=cst.Attribute(cst.Name("object"), cst.Name("__getattribute__")), + args=[cst.Arg(cst.Name("self")), cst.Arg(cst.SimpleString(f"'{func_name}'"))], ) result: cst.BaseExpression = cst.Call( - func=cst.Name('_mutmut_trampoline'), + func=cst.Name("_mutmut_trampoline"), args=[ - cst.Arg(_get_local_name(f'{mangled_name}_orig')), - cst.Arg(_get_local_name(f'{mangled_name}_mutants')), - cst.Arg(cst.Name('args')), - cst.Arg(cst.Name('kwargs')), - cst.Arg(cst.Name('None' if class_name is None else 'self')), + cst.Arg(_get_local_name(f"{mangled_name}_orig")), + cst.Arg(_get_local_name(f"{mangled_name}_mutants")), + cst.Arg(cst.Name("args")), + cst.Arg(cst.Name("kwargs")), + cst.Arg(cst.Name("None" if class_name is None else "self")), ], ) # for non-async functions, simply return the value or generator @@ -307,17 +330,17 @@ def _get_local_name(func_name: str) -> cst.BaseExpression: is_generator = _is_generator(function) if is_generator: # async for i in _mutmut_trampoline(...): yield i - result_statement = cst.For( - target=cst.Name('i'), + result_statement = cst.For( # type: ignore[assignment] + target=cst.Name("i"), iter=result, - body=cst.IndentedBlock([cst.SimpleStatementLine([cst.Expr(cst.Yield(cst.Name('i')))])]), + body=cst.IndentedBlock([cst.SimpleStatementLine([cst.Expr(cst.Yield(cst.Name("i")))])]), asynchronous=cst.Asynchronous(), ) else: # return await _mutmut_trampoline(...) result_statement = cst.SimpleStatementLine([cst.Return(cst.Await(result))]) - type_ignore_whitespace = cst.TrailingWhitespace(comment=cst.Comment('# type: ignore')) + type_ignore_whitespace = cst.TrailingWhitespace(comment=cst.Comment("# type: ignore")) function.whitespace_after_type_parameters return function.with_changes( @@ -333,7 +356,7 @@ def _get_local_name(func_name: str) -> cst.BaseExpression: def get_statements_until_func_or_class(statements: Sequence[MODULE_STATEMENT]) -> list[MODULE_STATEMENT]: """Get all statements until we encounter the first function or class definition""" - result = [] + result: list[MODULE_STATEMENT] = [] for stmt in statements: if m.matches(stmt, m.FunctionDef() | m.ClassDef()): @@ -342,6 +365,7 @@ def get_statements_until_func_or_class(statements: Sequence[MODULE_STATEMENT]) - return result + def group_by_top_level_node(mutations: Sequence[Mutation]) -> Mapping[cst.CSTNode, Sequence[Mutation]]: grouped: dict[cst.CSTNode, list[Mutation]] = defaultdict(list) for m in mutations: @@ -350,16 +374,19 @@ def group_by_top_level_node(mutations: Sequence[Mutation]) -> Mapping[cst.CSTNod return grouped + def pragma_no_mutate_lines(source: str) -> set[int]: return { i + 1 - for i, line in enumerate(source.split('\n')) - if '# pragma:' in line and 'no mutate' in line.partition('# pragma:')[-1] + for i, line in enumerate(source.split("\n")) + if "# pragma:" in line and "no mutate" in line.partition("# pragma:")[-1] } + def deep_replace(tree: cst.CSTNode, old_node: cst.CSTNode, new_node: cst.CSTNode) -> cst.CSTNode: """Like the CSTNode.deep_replace method, except that we only replace up to one occurrence of old_node.""" - return tree.visit(ChildReplacementTransformer(old_node, new_node)) # type: ignore + return tree.visit(ChildReplacementTransformer(old_node, new_node)) # type: ignore[return-value] + class ChildReplacementTransformer(cst.CSTTransformer): def __init__(self, old_node: cst.CSTNode, new_node: cst.CSTNode): @@ -373,30 +400,34 @@ def on_visit(self, node: cst.CSTNode) -> bool: # Also, we stop recursion when we already replaced the node. return not (self.replaced_node or node is self.old_node) - def on_leave(self, original_node: cst.CSTNode, updated_node: cst.CSTNode) -> cst.CSTNode: + def on_leave(self, original_node: cst.CSTNode, updated_node: cst.CSTNode) -> cst.CSTNode: # type: ignore[override] if original_node is self.old_node: self.replaced_node = True return self.new_node return updated_node + def _is_generator(function: cst.FunctionDef) -> bool: """Return True if the function has yield statement(s).""" visitor = IsGeneratorVisitor(function) function.visit(visitor) return visitor.is_generator + class IsGeneratorVisitor(cst.CSTVisitor): """Check if a function is a generator. We do so by checking if any child is a Yield statement, but not looking into inner function definitions.""" + def __init__(self, original_function: cst.FunctionDef): self.is_generator = False self.original_function: cst.FunctionDef = original_function - def visit_FunctionDef(self, node): + def visit_FunctionDef(self, node: cst.FunctionDef) -> bool | None: # do not recurse into inner function definitions if self.original_function != node: return False + return None - def visit_Yield(self, node): + def visit_Yield(self, node: cst.Yield) -> bool: self.is_generator = True return False diff --git a/src/mutmut/node_mutation.py b/src/mutmut/node_mutation.py index 25941408..bf4b1f1d 100644 --- a/src/mutmut/node_mutation.py +++ b/src/mutmut/node_mutation.py @@ -1,7 +1,12 @@ """This module contains the mutations for indidvidual nodes, e.g. replacing a != b with a == b.""" + import re -from typing import Any, Union, cast -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Sequence +from typing import Any +from typing import cast + import libcst as cst import libcst.matchers as m @@ -15,10 +20,9 @@ # pattern to match (nearly) all chars in a string that are not part of an escape sequence NON_ESCAPE_SEQUENCE = re.compile(r"((? Iterable[cst.BaseNumber]: - if isinstance(node, (cst.Integer, cst.Float)): + +def operator_number(node: cst.BaseNumber) -> Iterable[cst.BaseNumber]: + if isinstance(node, cst.Integer | cst.Float): yield node.with_changes(value=repr(node.evaluated_value + 1)) elif isinstance(node, cst.Imaginary): yield node.with_changes(value=repr(node.evaluated_value + 1j)) @@ -26,15 +30,11 @@ def operator_number( print("Unexpected number type", node) -def operator_string( - node: cst.BaseString -) -> Iterable[cst.BaseString]: +def operator_string(node: cst.BaseString) -> Iterable[cst.BaseString]: if isinstance(node, cst.SimpleString): value = node.value old_value = value - prefix = value[ - : min([x for x in [value.find('"'), value.find("'")] if x != -1]) - ] + prefix = value[: min([x for x in [value.find('"'), value.find("'")] if x != -1])] value = value[len(prefix) :] if value.startswith('"""') or value.startswith("'''"): @@ -58,18 +58,14 @@ def operator_string( yield node.with_changes(value=new_value) -def operator_lambda( - node: cst.Lambda -) -> Iterable[cst.Lambda]: +def operator_lambda(node: cst.Lambda) -> Iterable[cst.Lambda]: if m.matches(node, m.Lambda(body=m.Name("None"))): yield node.with_changes(body=cst.Integer("0")) else: yield node.with_changes(body=cst.Name("None")) -def operator_dict_arguments( - node: cst.Call -) -> Iterable[cst.Call]: +def operator_dict_arguments(node: cst.Call) -> Iterable[cst.Call]: """mutate dict(a=b, c=d) to dict(aXX=b, c=d) and dict(a=b, cXX=d)""" if not m.matches(node.func, m.Name(value="dict")): return @@ -82,18 +78,16 @@ def operator_dict_arguments( mutated_args = [ *node.args[:i], node.args[i].with_changes(keyword=mutated_keyword), - *node.args[i+1:], + *node.args[i + 1 :], ] yield node.with_changes(args=mutated_args) -def operator_arg_removal( - node: cst.Call -) -> Iterable[cst.Call]: +def operator_arg_removal(node: cst.Call) -> Iterable[cst.Call]: """try to drop each arg in a function call, e.g. foo(a, b) -> foo(b), foo(a)""" for i, arg in enumerate(node.args): # replace with None - if arg.star == '' and not m.matches(arg.value, m.Name("None")): + if arg.star == "" and not m.matches(arg.value, m.Name("None")): mutated_arg = arg.with_changes(value=cst.Name("None")) yield node.with_changes(args=[*node.args[:i], mutated_arg, *node.args[i + 1 :]]) @@ -104,59 +98,52 @@ def operator_arg_removal( supported_symmetric_str_methods_swap = [ - ("lower", "upper"), - ("upper", "lower"), - ("lstrip", "rstrip"), - ("rstrip", "lstrip"), - ("find", "rfind"), - ("rfind", "find"), - ("ljust", "rjust"), - ("rjust", "ljust"), - ("index", "rindex"), - ("rindex", "index"), - ("removeprefix", "removesuffix"), - ("removesuffix", "removeprefix"), - ("partition", "rpartition"), - ("rpartition", "partition") + ("lower", "upper"), + ("upper", "lower"), + ("lstrip", "rstrip"), + ("rstrip", "lstrip"), + ("find", "rfind"), + ("rfind", "find"), + ("ljust", "rjust"), + ("rjust", "ljust"), + ("index", "rindex"), + ("rindex", "index"), + ("removeprefix", "removesuffix"), + ("removesuffix", "removeprefix"), + ("partition", "rpartition"), + ("rpartition", "partition"), ] -supported_unsymmetrical_str_methods_swap = [ - ("split", "rsplit"), - ("rsplit", "split") -] +supported_unsymmetrical_str_methods_swap = [("split", "rsplit"), ("rsplit", "split")] + -def operator_symmetric_string_methods_swap( - node: cst.Call - ) -> Iterable[cst.Call]: - """try to swap string method to opposite e.g. a.lower() -> a.upper()""" +def operator_symmetric_string_methods_swap(node: cst.Call) -> Iterable[cst.Call]: + """try to swap string method to opposite e.g. a.lower() -> a.upper()""" - for old_call, new_call in supported_symmetric_str_methods_swap: - if m.matches(node.func, m.Attribute(value=m.DoNotCare(), attr=m.Name(value=old_call))): + for old_call, new_call in supported_symmetric_str_methods_swap: + if m.matches(node.func, m.Attribute(value=m.DoNotCare(), attr=m.Name(value=old_call))): func_name = cst.ensure_type(node.func, cst.Attribute).attr yield node.with_deep_changes(func_name, value=new_call) -def operator_unsymmetrical_string_methods_swap( - node: cst.Call -) -> Iterable[cst.Call]: + +def operator_unsymmetrical_string_methods_swap(node: cst.Call) -> Iterable[cst.Call]: """Try to handle specific mutations of string, which useful only in specific args combination.""" for old_call, new_call in supported_unsymmetrical_str_methods_swap: if m.matches(node.func, m.Attribute(attr=m.Name(value=old_call))): if old_call in {"split", "rsplit"}: # The logic of this "if" operator described here: # https://github.com/boxed/mutmut/pull/394#issuecomment-2977890188 - key_args: set[str] = {a.keyword.value for a in node.args if a.keyword} # sep or maxsplit or nothing + key_args: set[str] = {a.keyword.value for a in node.args if a.keyword} # sep or maxsplit or nothing if len(node.args) == 2 or "maxsplit" in key_args: func_name = cst.ensure_type(node.func, cst.Attribute).attr yield node.with_deep_changes(func_name, value=new_call) - -def operator_remove_unary_ops( - node: cst.UnaryOperation -) -> Iterable[cst.BaseExpression]: - if isinstance(node.operator, (cst.Not, cst.BitInvert)): +def operator_remove_unary_ops(node: cst.UnaryOperation) -> Iterable[cst.BaseExpression]: + if isinstance(node.operator, cst.Not | cst.BitInvert): yield node.expression + _keyword_mapping: dict[type[cst.CSTNode], type[cst.CSTNode]] = { cst.Is: cst.IsNot, cst.IsNot: cst.Is, @@ -166,9 +153,8 @@ def operator_remove_unary_ops( cst.Continue: cst.Break, } -def operator_keywords( - node: cst.CSTNode -) -> Iterable[cst.CSTNode]: + +def operator_keywords(node: cst.CSTNode) -> Iterable[cst.CSTNode]: yield from _simple_mutation_mapping(node, _keyword_mapping) @@ -182,6 +168,7 @@ def operator_name(node: cst.Name) -> Iterable[cst.CSTNode]: if node.value in name_mappings: yield node.with_changes(value=name_mappings[node.value]) + _operator_mapping: dict[type[cst.CSTNode], type[cst.CSTNode]] = { cst.Plus: cst.Minus, cst.Add: cst.Subtract, @@ -219,30 +206,34 @@ def operator_name(node: cst.Name) -> Iterable[cst.CSTNode]: cst.Or: cst.And, } -def operator_swap_op( - node: cst.CSTNode -) -> Iterable[cst.CSTNode]: - if m.matches(node, m.BinaryOperation() | m.UnaryOperation() | m.BooleanOperation() | m.ComparisonTarget() | m.AugAssign()): - typed_node = cast(Union[cst.BinaryOperation, cst.UnaryOperation, cst.BooleanOperation, cst.ComparisonTarget, cst.AugAssign], node) + +def operator_swap_op(node: cst.CSTNode) -> Iterable[cst.CSTNode]: + if m.matches( + node, + m.BinaryOperation() | m.UnaryOperation() | m.BooleanOperation() | m.ComparisonTarget() | m.AugAssign(), + ): + typed_node = cast( + cst.BinaryOperation | cst.UnaryOperation | cst.BooleanOperation | cst.ComparisonTarget | cst.AugAssign, + node, + ) operator = typed_node.operator for new_operator in _simple_mutation_mapping(operator, _operator_mapping): yield node.with_changes(operator=new_operator) -def operator_augmented_assignment( - node: cst.AugAssign -) -> Iterable[cst.Assign]: +def operator_augmented_assignment(node: cst.AugAssign) -> Iterable[cst.Assign]: """mutate all augmented assignments (+=, *=, |=, etc.) to normal = assignments""" yield cst.Assign([cst.AssignTarget(node.target)], node.value, node.semicolon) def operator_assignment( - node: Union[cst.Assign, cst.AnnAssign] + node: cst.Assign | cst.AnnAssign, ) -> Iterable[cst.CSTNode]: """mutate `a = b` to `a = None` and `a = None` to `a = ""`""" if not node.value: # do not mutate `a: sometype` to an assignment `a: sometype = ""` return + mutated_value: cst.BaseExpression if m.matches(node.value, m.Name("None")): mutated_value = cst.SimpleString('""') else: @@ -250,16 +241,18 @@ def operator_assignment( yield node.with_changes(value=mutated_value) + def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]: """Drop the case statements in a match.""" if len(node.cases) > 1: for i in range(len(node.cases)): - yield node.with_changes(cases=[*node.cases[:i], *node.cases[i+1:]]) + yield node.with_changes(cases=[*node.cases[:i], *node.cases[i + 1 :]]) + # Operators that should be called on specific node types mutation_operators: OPERATORS_TYPE = [ - (cst.BaseNumber, operator_number), - (cst.BaseString, operator_string), + (cst.BaseNumber, operator_number), # type: ignore[type-abstract] + (cst.BaseString, operator_string), # type: ignore[type-abstract] (cst.Name, operator_name), (cst.Assign, operator_assignment), (cst.AnnAssign, operator_assignment), @@ -270,8 +263,8 @@ def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]: (cst.Call, operator_symmetric_string_methods_swap), (cst.Call, operator_unsymmetrical_string_methods_swap), (cst.Lambda, operator_lambda), - (cst.CSTNode, operator_keywords), - (cst.CSTNode, operator_swap_op), + (cst.CSTNode, operator_keywords), # type: ignore[type-abstract] + (cst.CSTNode, operator_swap_op), # type: ignore[type-abstract] (cst.Match, operator_match), ] diff --git a/src/mutmut/trampoline_templates.py b/src/mutmut/trampoline_templates.py index 77cb5eef..0b051938 100644 --- a/src/mutmut/trampoline_templates.py +++ b/src/mutmut/trampoline_templates.py @@ -1,22 +1,29 @@ -CLASS_NAME_SEPARATOR = 'ǁ' +CLASS_NAME_SEPARATOR = "ǁ" -def create_trampoline_lookup(*, orig_name, mutants, class_name): + +def create_trampoline_lookup(*, orig_name: str, mutants: list[str], class_name: str | None) -> str: mangled_name = mangle_function_name(name=orig_name, class_name=class_name) - mutants_dict = f'{mangled_name}__mutmut_mutants : ClassVar[MutantDict] = {{ # type: ignore\n' + ', \n '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}' + mutants_dict = ( + f"{mangled_name}__mutmut_mutants : ClassVar[MutantDict] = {{ # type: ignore\n" + + ", \n ".join(f"{repr(m)}: {m}" for m in mutants) + + "\n}" + ) return f""" {mutants_dict} {mangled_name}__mutmut_orig.__name__ = '{mangled_name}' """ -def mangle_function_name(*, name, class_name): + +def mangle_function_name(*, name: str, class_name: str | None) -> str: assert CLASS_NAME_SEPARATOR not in name if class_name: assert CLASS_NAME_SEPARATOR not in class_name - prefix = f'x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}' + prefix = f"x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}" else: - prefix = 'x_' - return f'{prefix}{name}' + prefix = "x_" + return f"{prefix}{name}" + # noinspection PyUnresolvedReferences # language=python diff --git a/src/mutmut/type_checking.py b/src/mutmut/type_checking.py index 999fdf2d..40e4ee02 100644 --- a/src/mutmut/type_checking.py +++ b/src/mutmut/type_checking.py @@ -1,8 +1,9 @@ -from typing import cast import json import subprocess -from pathlib import Path from dataclasses import dataclass +from pathlib import Path +from typing import Any +from typing import cast @dataclass @@ -16,83 +17,96 @@ class TypeCheckingError: def run_type_checker(type_check_command: list[str]) -> list[TypeCheckingError]: errors = [] - completed_process = subprocess.run(type_check_command, capture_output=True, encoding='utf-8') + completed_process = subprocess.run(type_check_command, capture_output=True, encoding="utf-8") try: - if 'mypy' in type_check_command: + if "mypy" in type_check_command: report = [json.loads(line) for line in completed_process.stdout.splitlines()] else: report = json.loads(completed_process.stdout) - except json.JSONDecodeError as e: - raise Exception(f'type check command did not return JSON. Got: {completed_process.stdout} (stderr: {completed_process.stderr})') - - if 'pyrefly' in type_check_command: - errors = parse_pyrefly_report(cast(dict, report)) - elif 'mypy' in type_check_command: + except json.JSONDecodeError: + raise Exception( + f"type check command did not return JSON. Got: {completed_process.stdout} (stderr: {completed_process.stderr})" + ) + + if "pyrefly" in type_check_command: + errors = parse_pyrefly_report(cast(dict[str, Any], report)) + elif "mypy" in type_check_command: errors = parse_mypy_report(report) - elif 'ty' in type_check_command: + elif "ty" in type_check_command: errors = parse_ty_report(report) else: - errors = parse_pyright_report(cast(dict, report)) + errors = parse_pyright_report(cast(dict[str, Any], report)) return errors -def parse_pyright_report(result: dict) -> list[TypeCheckingError]: - if not 'generalDiagnostics' in result: +def parse_pyright_report(result: dict[str, Any]) -> list[TypeCheckingError]: + if "generalDiagnostics" not in result: raise Exception(f'Invalid pyright report. Could not find key "generalDiagnostics". Found: {set(result.keys())}') errors = [] - for diagnostic in result['generalDiagnostics']: - errors.append(TypeCheckingError( - file_path=Path(diagnostic['file']), - line_number=diagnostic['range']['start']['line'] + 1, - error_description=diagnostic['message'], - )) - + for diagnostic in result["generalDiagnostics"]: + errors.append( + TypeCheckingError( + file_path=Path(diagnostic["file"]), + line_number=diagnostic["range"]["start"]["line"] + 1, + error_description=diagnostic["message"], + ) + ) + return errors - -def parse_pyrefly_report(result: dict) -> list[TypeCheckingError]: - if not 'errors' in result: + + +def parse_pyrefly_report(result: dict[str, Any]) -> list[TypeCheckingError]: + if "errors" not in result: raise Exception(f'Invalid pyrefly report. Could not find key "errors". Found: {set(result.keys())}') errors = [] - for error in result['errors']: - errors.append(TypeCheckingError( - file_path=Path(error['path']).absolute(), - line_number=error['line'], - error_description=error['concise_description'], - )) + for error in result["errors"]: + errors.append( + TypeCheckingError( + file_path=Path(error["path"]).absolute(), + line_number=error["line"], + error_description=error["concise_description"], + ) + ) return errors -def parse_mypy_report(result: list[dict]) -> list[TypeCheckingError]: + +def parse_mypy_report(result: list[dict[str, Any]]) -> list[TypeCheckingError]: errors = [] for diagnostic in result: - if diagnostic['severity'] != 'error': + if diagnostic["severity"] != "error": continue - errors.append(TypeCheckingError( - file_path=Path(diagnostic['file']).absolute(), - line_number=diagnostic['line'], - error_description=diagnostic['message'], - )) + errors.append( + TypeCheckingError( + file_path=Path(diagnostic["file"]).absolute(), + line_number=diagnostic["line"], + error_description=diagnostic["message"], + ) + ) return errors -def parse_ty_report(result: list[dict]) -> list[TypeCheckingError]: + +def parse_ty_report(result: list[dict[str, Any]]) -> list[TypeCheckingError]: errors = [] for diagnostic in result: # assuming the gitlab code quality report format, these severities seem okay # https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format - if diagnostic['severity'] not in ('major', 'critical', 'blocker'): + if diagnostic["severity"] not in ("major", "critical", "blocker"): continue - errors.append(TypeCheckingError( - file_path=Path(diagnostic['location']['path']).absolute(), - line_number=diagnostic['location']['positions']['begin']['line'], - error_description=diagnostic['description'], - )) + errors.append( + TypeCheckingError( + file_path=Path(diagnostic["location"]["path"]).absolute(), + line_number=diagnostic["location"]["positions"]["begin"]["line"], + error_description=diagnostic["description"], + ) + ) - return errors \ No newline at end of file + return errors diff --git a/tests/data/test_generation/invalid_syntax.py b/tests/data/test_generation/invalid_syntax.py index 6176d76e..01050dc8 100644 --- a/tests/data/test_generation/invalid_syntax.py +++ b/tests/data/test_generation/invalid_syntax.py @@ -1,4 +1,4 @@ """This file contains invalid python syntax""" def foo(): - return 1 ///// 2 \ No newline at end of file + return 1 ///// 2 diff --git a/tests/data/test_generation/valid_syntax_1.py b/tests/data/test_generation/valid_syntax_1.py index 068706ac..3fa82f10 100644 --- a/tests/data/test_generation/valid_syntax_1.py +++ b/tests/data/test_generation/valid_syntax_1.py @@ -1,2 +1,2 @@ def foo(): - return 1 + 2 \ No newline at end of file + return 1 + 2 diff --git a/tests/data/test_generation/valid_syntax_2.py b/tests/data/test_generation/valid_syntax_2.py index 137f4b7e..6d378b7e 100644 --- a/tests/data/test_generation/valid_syntax_2.py +++ b/tests/data/test_generation/valid_syntax_2.py @@ -1,2 +1,2 @@ def foo(): - return 2 + 3 \ No newline at end of file + return 2 + 3 diff --git a/tests/data/test_generation/valid_syntax_3.py b/tests/data/test_generation/valid_syntax_3.py index 82e60af8..d4007299 100644 --- a/tests/data/test_generation/valid_syntax_3.py +++ b/tests/data/test_generation/valid_syntax_3.py @@ -1,2 +1,2 @@ def foo(): - return 3 + 4 \ No newline at end of file + return 3 + 4 diff --git a/tests/data/test_generation/valid_syntax_4.py b/tests/data/test_generation/valid_syntax_4.py index d1dba07d..716532c8 100644 --- a/tests/data/test_generation/valid_syntax_4.py +++ b/tests/data/test_generation/valid_syntax_4.py @@ -1,2 +1,2 @@ def foo(): - return 4 + 5 \ No newline at end of file + return 4 + 5 diff --git a/tests/data/test_generation/valid_syntax_5.py b/tests/data/test_generation/valid_syntax_5.py index dac3e1ea..524b6222 100644 --- a/tests/data/test_generation/valid_syntax_5.py +++ b/tests/data/test_generation/valid_syntax_5.py @@ -1,2 +1,2 @@ def foo(): - return 5 + 6 \ No newline at end of file + return 5 + 6 diff --git a/tests/e2e/e2e_utils.py b/tests/e2e/e2e_utils.py index 9f6338e2..30becf80 100644 --- a/tests/e2e/e2e_utils.py +++ b/tests/e2e/e2e_utils.py @@ -6,7 +6,10 @@ from typing import Any import mutmut -from mutmut.__main__ import SourceFileMutationData, _run, ensure_config_loaded, walk_source_files +from mutmut.__main__ import SourceFileMutationData +from mutmut.__main__ import _run +from mutmut.__main__ import ensure_config_loaded +from mutmut.__main__ import walk_source_files @contextmanager @@ -36,12 +39,12 @@ def read_all_stats_for_project(project_path: Path) -> dict[str, dict]: def read_json_file(path: Path): - with open(path, 'r') as file: + with open(path) as file: return json.load(file) def write_json_file(path: Path, data: Any): - with open(path, 'w') as file: + with open(path, "w") as file: json.dump(data, file, indent=2) diff --git a/tests/e2e/test_e2e_py3_14.py b/tests/e2e/test_e2e_py3_14.py index 9ea2ba00..0952df9c 100644 --- a/tests/e2e/test_e2e_py3_14.py +++ b/tests/e2e/test_e2e_py3_14.py @@ -1,13 +1,12 @@ -from inline_snapshot import snapshot -import pytest import sys +import pytest +from inline_snapshot import snapshot + from tests.e2e.e2e_utils import run_mutmut_on_project -@pytest.mark.skipif( - sys.version_info < (3, 14), reason="Can only test python 3.14 features on 3.14" -) +@pytest.mark.skipif(sys.version_info < (3, 14), reason="Can only test python 3.14 features on 3.14") def test_python_3_14_result_snapshot(): assert run_mutmut_on_project("py3_14_features") == snapshot( { diff --git a/tests/test_generation_error_handling.py b/tests/test_generation_error_handling.py index a124ab54..2c601f31 100644 --- a/tests/test_generation_error_handling.py +++ b/tests/test_generation_error_handling.py @@ -3,11 +3,11 @@ import pytest -import mutmut import mutmut.__main__ -from mutmut.__main__ import InvalidGeneratedSyntaxException, create_mutants +from mutmut.__main__ import InvalidGeneratedSyntaxException +from mutmut.__main__ import create_mutants -source_dir = Path(__file__).parent / 'data' / 'test_generation' +source_dir = Path(__file__).parent / "data" / "test_generation" source_dir = source_dir.relative_to(Path.cwd()) @@ -20,7 +20,7 @@ def test_mutant_generation_raises_exception_on_invalid_syntax(monkeypatch): mutmut._reset_globals() mutmut.config = MockConfig() - shutil.rmtree('mutants', ignore_errors=True) + shutil.rmtree("mutants", ignore_errors=True) source_files = [ source_dir / "valid_syntax_1.py", @@ -38,4 +38,4 @@ def test_mutant_generation_raises_exception_on_invalid_syntax(monkeypatch): # should raise a warning, because libcst is not able to parse invalid_syntax.py with pytest.warns(SyntaxWarning): create_mutants(max_children=2) - assert 'invalid_syntax.py' in str(excinfo.value) + assert "invalid_syntax.py" in str(excinfo.value) diff --git a/tests/test_mutation regression.py b/tests/test_mutation regression.py index f2d69e31..6d567025 100644 --- a/tests/test_mutation regression.py +++ b/tests/test_mutation regression.py @@ -1,16 +1,13 @@ -from inline_snapshot import snapshot import libcst as cst +from inline_snapshot import snapshot -from mutmut.file_mutation import mutate_file_contents, create_trampoline_wrapper +from mutmut.file_mutation import create_trampoline_wrapper +from mutmut.file_mutation import mutate_file_contents -def _get_trampoline_wrapper( - source: str, mangled_name: str, class_name: str | None = None -) -> str: +def _get_trampoline_wrapper(source: str, mangled_name: str, class_name: str | None = None) -> str: function = cst.ensure_type(cst.parse_statement(source), cst.FunctionDef) - trampoline = create_trampoline_wrapper( - function, mangled_name, class_name=class_name - ) + trampoline = create_trampoline_wrapper(function, mangled_name, class_name=class_name) return cst.Module([trampoline]).code.strip() @@ -55,9 +52,7 @@ def foo(p1, p2=None, /, p_or_kw=None, *, kw): def test_create_trampoline_wrapper_for_class_method(): source = "def foo(self, a, b): pass" - assert _get_trampoline_wrapper( - source, "x_foo__mutmut", class_name="Person" - ) == snapshot("""\ + assert _get_trampoline_wrapper(source, "x_foo__mutmut", class_name="Person") == snapshot("""\ def foo(self, a, b): args = [a, b]# type: ignore kwargs = {}# type: ignore diff --git a/tests/test_mutation.py b/tests/test_mutation.py index 76200a6b..7c9dd4d9 100644 --- a/tests/test_mutation.py +++ b/tests/test_mutation.py @@ -1,24 +1,23 @@ -import pytest import os -from typing import Union -from unittest.mock import Mock, patch +from unittest.mock import Mock +from unittest.mock import patch import libcst as cst +import pytest import mutmut -from mutmut.__main__ import ( - CLASS_NAME_SEPARATOR, - CatchOutput, - MutmutProgrammaticFailException, - get_diff_for_mutant, - orig_function_and_class_names_from_key, - run_forced_fail_test, -) -from mutmut.file_mutation import create_mutations, mutate_file_contents -from mutmut.trampoline_templates import mangle_function_name, trampoline_impl - - -def mutants_for_source(source: str, covered_lines: Union[set[int], None] = None) -> list[str]: +from mutmut.__main__ import CLASS_NAME_SEPARATOR +from mutmut.__main__ import CatchOutput +from mutmut.__main__ import MutmutProgrammaticFailException +from mutmut.__main__ import get_diff_for_mutant +from mutmut.__main__ import orig_function_and_class_names_from_key +from mutmut.__main__ import run_forced_fail_test +from mutmut.file_mutation import create_mutations +from mutmut.file_mutation import mutate_file_contents +from mutmut.trampoline_templates import mangle_function_name + + +def mutants_for_source(source: str, covered_lines: set[int] | None = None) -> list[str]: module, mutated_nodes = create_mutations(source, covered_lines) mutants: list[str] = [module.deep_replace(m.original_node, m.mutated_node).code for m in mutated_nodes] # type: ignore @@ -26,131 +25,207 @@ def mutants_for_source(source: str, covered_lines: Union[set[int], None] = None) def mutated_module(source: str) -> str: - mutated_code, _ = mutate_file_contents('', source) + mutated_code, _ = mutate_file_contents("", source) return mutated_code @pytest.mark.parametrize( - 'original, expected', [ - ('foo(a, *args, **kwargs)', [ - 'foo(*args, **kwargs)', - 'foo(None, *args, **kwargs)', - 'foo(a, **kwargs)', - 'foo(a, *args, )', - ]), + "original, expected", + [ + ( + "foo(a, *args, **kwargs)", + [ + "foo(*args, **kwargs)", + "foo(None, *args, **kwargs)", + "foo(a, **kwargs)", + "foo(a, *args, )", + ], + ), # ('break', 'continue'), # probably a bad idea. Can introduce infinite loops. - ('break', 'return'), - ('continue', 'break'), - ('a.lower()', 'a.upper()'), - ('a.upper()', 'a.lower()'), - ('a.b.lower()', 'a.b.upper()'), - ('a.b.upper()', 'a.b.lower()'), - ('a.lstrip("!")', ['a.rstrip("!")', 'a.lstrip("XX!XX")', 'a.lstrip(None)']), - ('a.rstrip("!")', ['a.lstrip("!")', 'a.rstrip("XX!XX")', 'a.rstrip(None)']), - ('a.find("!")', ['a.rfind("!")', 'a.find("XX!XX")', 'a.find(None)']), - ('a.rfind("!")', ['a.find("!")', 'a.rfind("XX!XX")', 'a.rfind(None)']), - ('a.ljust(10, "+")', [ - 'a.ljust("+")', 'a.ljust(10, "XX+XX")', - 'a.ljust(10, )', 'a.ljust(10, None)', - 'a.ljust(11, "+")', 'a.ljust(None, "+")', - 'a.rjust(10, "+")' - ]), - ('a.rjust(10, "+")', [ - 'a.ljust(10, "+")', 'a.rjust("+")', - 'a.rjust(10, "XX+XX")', 'a.rjust(10, )', - 'a.rjust(10, None)', 'a.rjust(11, "+")', - 'a.rjust(None, "+")' - ]), - ('a.index("+")', ['a.rindex("+")', 'a.index("XX+XX")', 'a.index(None)']), - ('a.rindex("+")', ['a.index("+")', 'a.rindex("XX+XX")', 'a.rindex(None)']), - ('a.split()', []), - ('a.rsplit()', []), - ('a.split(" ")', ['a.split("XX XX")', 'a.split(None)']), - ('a.rsplit(" ")', ['a.rsplit("XX XX")', 'a.rsplit(None)']), - ('a.split(sep="")', ['a.split(sep="XXXX")', 'a.split(sep=None)']), - ('a.rsplit(sep="")', ['a.rsplit(sep="XXXX")', 'a.rsplit(sep=None)']), - ('a.split(maxsplit=-1)', [ - 'a.rsplit(maxsplit=-1)', 'a.split(maxsplit=+1)', 'a.split(maxsplit=-2)', 'a.split(maxsplit=None)' - ]), - ('a.rsplit(maxsplit=-1)', [ - 'a.split(maxsplit=-1)', 'a.rsplit(maxsplit=+1)', 'a.rsplit(maxsplit=-2)', 'a.rsplit(maxsplit=None)' - ]), - ('a.split(" ", maxsplit=-1)', [ - 'a.split(" ", )', 'a.split(" ", maxsplit=+1)', 'a.split(" ", maxsplit=-2)', - 'a.split(" ", maxsplit=None)', 'a.split("XX XX", maxsplit=-1)', 'a.split(None, maxsplit=-1)', - 'a.split(maxsplit=-1)', 'a.rsplit(" ", maxsplit=-1)' - ]), - ('a.rsplit(" ", maxsplit=-1)', [ - 'a.rsplit(" ", )', 'a.rsplit(" ", maxsplit=+1)', 'a.rsplit(" ", maxsplit=-2)', - 'a.rsplit(" ", maxsplit=None)', 'a.rsplit("XX XX", maxsplit=-1)', 'a.rsplit(None, maxsplit=-1)', - 'a.rsplit(maxsplit=-1)', 'a.split(" ", maxsplit=-1)' - ]), - ('a.split(maxsplit=1)', ['a.split(maxsplit=2)', 'a.split(maxsplit=None)', 'a.rsplit(maxsplit=1)']), - ('a.rsplit(maxsplit=1)', ['a.rsplit(maxsplit=2)', 'a.rsplit(maxsplit=None)', 'a.split(maxsplit=1)']), - ('a.split(" ", 1)', [ - 'a.rsplit(" ", 1)', 'a.split(" ", )', 'a.split(" ", 2)', 'a.split(" ", None)', - 'a.split("XX XX", 1)', 'a.split(1)', 'a.split(None, 1)' - ]), - ('a.rsplit(" ", 1)', [ - 'a.rsplit(" ", )', 'a.rsplit(" ", 2)', 'a.rsplit(" ", None)', 'a.rsplit("XX XX", 1)', - 'a.rsplit(1)', 'a.rsplit(None, 1)', 'a.split(" ", 1)' - ]), - ('a.split(" ", maxsplit=1)', [ - 'a.rsplit(" ", maxsplit=1)', 'a.split(" ", )', 'a.split(" ", maxsplit=2)', 'a.split(" ", maxsplit=None)', - 'a.split("XX XX", maxsplit=1)', 'a.split(None, maxsplit=1)', 'a.split(maxsplit=1)' - ]), - ('a.rsplit(" ", maxsplit=1)', [ - 'a.rsplit(" ", )', 'a.rsplit(" ", maxsplit=2)', 'a.rsplit(" ", maxsplit=None)', - 'a.rsplit("XX XX", maxsplit=1)', 'a.rsplit(None, maxsplit=1)', 'a.rsplit(maxsplit=1)', - 'a.split(" ", maxsplit=1)' - ]), - ('a.removeprefix("+")', ['a.removesuffix("+")', 'a.removeprefix("XX+XX")', 'a.removeprefix(None)']), - ('a.removesuffix("+")', ['a.removeprefix("+")', 'a.removesuffix("XX+XX")', 'a.removesuffix(None)']), - ('a.partition("++")', ['a.rpartition("++")', 'a.partition("XX++XX")', 'a.partition(None)']), - ('a.rpartition("++")', ['a.partition("++")', 'a.rpartition("XX++XX")', 'a.rpartition(None)']), - ('a(b)', 'a(None)'), + ("break", "return"), + ("continue", "break"), + ("a.lower()", "a.upper()"), + ("a.upper()", "a.lower()"), + ("a.b.lower()", "a.b.upper()"), + ("a.b.upper()", "a.b.lower()"), + ('a.lstrip("!")', ['a.rstrip("!")', 'a.lstrip("XX!XX")', "a.lstrip(None)"]), + ('a.rstrip("!")', ['a.lstrip("!")', 'a.rstrip("XX!XX")', "a.rstrip(None)"]), + ('a.find("!")', ['a.rfind("!")', 'a.find("XX!XX")', "a.find(None)"]), + ('a.rfind("!")', ['a.find("!")', 'a.rfind("XX!XX")', "a.rfind(None)"]), + ( + 'a.ljust(10, "+")', + [ + 'a.ljust("+")', + 'a.ljust(10, "XX+XX")', + "a.ljust(10, )", + "a.ljust(10, None)", + 'a.ljust(11, "+")', + 'a.ljust(None, "+")', + 'a.rjust(10, "+")', + ], + ), + ( + 'a.rjust(10, "+")', + [ + 'a.ljust(10, "+")', + 'a.rjust("+")', + 'a.rjust(10, "XX+XX")', + "a.rjust(10, )", + "a.rjust(10, None)", + 'a.rjust(11, "+")', + 'a.rjust(None, "+")', + ], + ), + ('a.index("+")', ['a.rindex("+")', 'a.index("XX+XX")', "a.index(None)"]), + ('a.rindex("+")', ['a.index("+")', 'a.rindex("XX+XX")', "a.rindex(None)"]), + ("a.split()", []), + ("a.rsplit()", []), + ('a.split(" ")', ['a.split("XX XX")', "a.split(None)"]), + ('a.rsplit(" ")', ['a.rsplit("XX XX")', "a.rsplit(None)"]), + ('a.split(sep="")', ['a.split(sep="XXXX")', "a.split(sep=None)"]), + ('a.rsplit(sep="")', ['a.rsplit(sep="XXXX")', "a.rsplit(sep=None)"]), + ( + "a.split(maxsplit=-1)", + ["a.rsplit(maxsplit=-1)", "a.split(maxsplit=+1)", "a.split(maxsplit=-2)", "a.split(maxsplit=None)"], + ), + ( + "a.rsplit(maxsplit=-1)", + ["a.split(maxsplit=-1)", "a.rsplit(maxsplit=+1)", "a.rsplit(maxsplit=-2)", "a.rsplit(maxsplit=None)"], + ), + ( + 'a.split(" ", maxsplit=-1)', + [ + 'a.split(" ", )', + 'a.split(" ", maxsplit=+1)', + 'a.split(" ", maxsplit=-2)', + 'a.split(" ", maxsplit=None)', + 'a.split("XX XX", maxsplit=-1)', + "a.split(None, maxsplit=-1)", + "a.split(maxsplit=-1)", + 'a.rsplit(" ", maxsplit=-1)', + ], + ), + ( + 'a.rsplit(" ", maxsplit=-1)', + [ + 'a.rsplit(" ", )', + 'a.rsplit(" ", maxsplit=+1)', + 'a.rsplit(" ", maxsplit=-2)', + 'a.rsplit(" ", maxsplit=None)', + 'a.rsplit("XX XX", maxsplit=-1)', + "a.rsplit(None, maxsplit=-1)", + "a.rsplit(maxsplit=-1)", + 'a.split(" ", maxsplit=-1)', + ], + ), + ("a.split(maxsplit=1)", ["a.split(maxsplit=2)", "a.split(maxsplit=None)", "a.rsplit(maxsplit=1)"]), + ("a.rsplit(maxsplit=1)", ["a.rsplit(maxsplit=2)", "a.rsplit(maxsplit=None)", "a.split(maxsplit=1)"]), + ( + 'a.split(" ", 1)', + [ + 'a.rsplit(" ", 1)', + 'a.split(" ", )', + 'a.split(" ", 2)', + 'a.split(" ", None)', + 'a.split("XX XX", 1)', + "a.split(1)", + "a.split(None, 1)", + ], + ), + ( + 'a.rsplit(" ", 1)', + [ + 'a.rsplit(" ", )', + 'a.rsplit(" ", 2)', + 'a.rsplit(" ", None)', + 'a.rsplit("XX XX", 1)', + "a.rsplit(1)", + "a.rsplit(None, 1)", + 'a.split(" ", 1)', + ], + ), + ( + 'a.split(" ", maxsplit=1)', + [ + 'a.rsplit(" ", maxsplit=1)', + 'a.split(" ", )', + 'a.split(" ", maxsplit=2)', + 'a.split(" ", maxsplit=None)', + 'a.split("XX XX", maxsplit=1)', + "a.split(None, maxsplit=1)", + "a.split(maxsplit=1)", + ], + ), + ( + 'a.rsplit(" ", maxsplit=1)', + [ + 'a.rsplit(" ", )', + 'a.rsplit(" ", maxsplit=2)', + 'a.rsplit(" ", maxsplit=None)', + 'a.rsplit("XX XX", maxsplit=1)', + "a.rsplit(None, maxsplit=1)", + "a.rsplit(maxsplit=1)", + 'a.split(" ", maxsplit=1)', + ], + ), + ('a.removeprefix("+")', ['a.removesuffix("+")', 'a.removeprefix("XX+XX")', "a.removeprefix(None)"]), + ('a.removesuffix("+")', ['a.removeprefix("+")', 'a.removesuffix("XX+XX")', "a.removesuffix(None)"]), + ('a.partition("++")', ['a.rpartition("++")', 'a.partition("XX++XX")', "a.partition(None)"]), + ('a.rpartition("++")', ['a.partition("++")', 'a.rpartition("XX++XX")', "a.rpartition(None)"]), + ("a(b)", "a(None)"), ("dict(a=None)", ["dict(aXX=None)"]), - ("dict(a=b)", ["dict(aXX=b)", 'dict(a=None)']), - ('lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=False)))', [ - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=True)))', - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=None)))', - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(showXX=False)))', - 'lambda **kwargs: Variable.integer(**setdefaults(None, dict(show=False)))', - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, None))', - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, ))', - 'lambda **kwargs: Variable.integer(**setdefaults(dict(show=False)))', - # TODO: this mutant would exist if we also mutate single-arg arglists (see implementation) - # 'lambda **kwargs: Variable.integer()', - 'lambda **kwargs: None', - ]), - ('x: list[A | None]', []), - ('a: Optional[int] = None', 'a: Optional[int] = ""'), - ('a: int = 1', ['a: int = 2', 'a: int = None']), - ('a: str = "FoO"', ['a: str = "XXFoOXX"', 'a: str = "foo"', 'a: str = "FOO"', 'a: str = None']), - (r'a: str = "Fo\t"', [r'a: str = "XXFo\tXX"', r'a: str = "FO\t"', r'a: str = "fo\t"', 'a: str = None']), - (r'a: str = "Fo\N{ghost} \U11223344"', [r'a: str = "XXFo\N{ghost} \U11223344XX"', r'a: str = "FO\N{GHOST} \U11223344"', r'a: str = "fo\N{ghost} \U11223344"', 'a: str = None']), - ('lambda: 0', ['lambda: 1', 'lambda: None']), - ("1 in (1, 2)", ['2 in (1, 2)', '1 not in (1, 2)', '1 in (2, 2)', '1 in (1, 3)']), - ('1+1', ['2+1', '1 - 1', '1+2']), - ('1', '2'), - ('1-1', ['2-1', '1 + 1', '1-2']), - ('1*1', ['2*1', '1 / 1', '1*2']), - ('1/1', ['2/1', '1 * 1', '1/2']), - ('1//1', ['2//1', '1 / 1', '1//2']), - ('1%1', ['2%1', '1 / 1', '1%2']), - ('1<<1', ['2<<1', '1 >> 1', '1<<2']), - ('1>>1', ['2>>1', '1 << 1', '1>>2']), - ('a&b', ['a | b']), - ('a|b', ['a & b']), - ('a^b', ['a & b']), - ('a**b', ['a * b']), - ('~a', ['a']), + ("dict(a=b)", ["dict(aXX=b)", "dict(a=None)"]), + ( + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=False)))", + [ + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=True)))", + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=None)))", + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(showXX=False)))", + "lambda **kwargs: Variable.integer(**setdefaults(None, dict(show=False)))", + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, None))", + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, ))", + "lambda **kwargs: Variable.integer(**setdefaults(dict(show=False)))", + # TODO: this mutant would exist if we also mutate single-arg arglists (see implementation) + # 'lambda **kwargs: Variable.integer()', + "lambda **kwargs: None", + ], + ), + ("x: list[A | None]", []), + ("a: Optional[int] = None", 'a: Optional[int] = ""'), + ("a: int = 1", ["a: int = 2", "a: int = None"]), + ('a: str = "FoO"', ['a: str = "XXFoOXX"', 'a: str = "foo"', 'a: str = "FOO"', "a: str = None"]), + (r'a: str = "Fo\t"', [r'a: str = "XXFo\tXX"', r'a: str = "FO\t"', r'a: str = "fo\t"', "a: str = None"]), + ( + r'a: str = "Fo\N{ghost} \U11223344"', + [ + r'a: str = "XXFo\N{ghost} \U11223344XX"', + r'a: str = "FO\N{GHOST} \U11223344"', + r'a: str = "fo\N{ghost} \U11223344"', + "a: str = None", + ], + ), + ("lambda: 0", ["lambda: 1", "lambda: None"]), + ("1 in (1, 2)", ["2 in (1, 2)", "1 not in (1, 2)", "1 in (2, 2)", "1 in (1, 3)"]), + ("1+1", ["2+1", "1 - 1", "1+2"]), + ("1", "2"), + ("1-1", ["2-1", "1 + 1", "1-2"]), + ("1*1", ["2*1", "1 / 1", "1*2"]), + ("1/1", ["2/1", "1 * 1", "1/2"]), + ("1//1", ["2//1", "1 / 1", "1//2"]), + ("1%1", ["2%1", "1 / 1", "1%2"]), + ("1<<1", ["2<<1", "1 >> 1", "1<<2"]), + ("1>>1", ["2>>1", "1 << 1", "1>>2"]), + ("a&b", ["a | b"]), + ("a|b", ["a & b"]), + ("a^b", ["a & b"]), + ("a**b", ["a * b"]), + ("~a", ["a"]), # ('1.0', '1.0000000000000002'), # using numpy features - ('1.0', '2.0'), - ('0.1', '1.1'), - ('1e-3', '1.001'), - ('True', 'False'), - ('False', 'True'), + ("1.0", "2.0"), + ("0.1", "1.1"), + ("1e-3", "1.001"), + ("True", "False"), + ("False", "True"), ('"FoO"', ['"XXFoOXX"', '"foo"', '"FOO"']), ("'FoO'", ["'XXFoOXX'", "'foo'", "'FOO'"]), ("u'FoO'", ["u'XXFoOXX'", "u'foo'", "u'FOO'"]), @@ -159,72 +234,73 @@ def mutated_module(source: str) -> str: ("0o10", "9"), ("0x10", "17"), ("0b10", "3"), - ("1<2", ['2<2', '1 <= 2', '1<3']), - ('(1, 2)', ['(2, 2)', '(1, 3)']), - ("1 not in (1, 2)", ['2 not in (1, 2)', '1 in (1, 2)', '1 not in (2, 2)', '1 not in (1, 3)']), # two spaces here because "not in" is two words + ("1<2", ["2<2", "1 <= 2", "1<3"]), + ("(1, 2)", ["(2, 2)", "(1, 3)"]), + ( + "1 not in (1, 2)", + ["2 not in (1, 2)", "1 in (1, 2)", "1 not in (2, 2)", "1 not in (1, 3)"], + ), # two spaces here because "not in" is two words ("foo is foo", "foo is not foo"), ("foo is not foo", "foo is foo"), - ('a or b', 'a and b'), - ('a and b', 'a or b'), - ('not a', 'a'), - ('a < b', ['a <= b']), - ('a <= b', ['a < b']), - ('a > b', ['a >= b']), - ('a >= b', ['a > b']), - ('a == b', ['a != b']), - ('a != b', ['a == b']), - ('a = b', 'a = None'), - ('a = b = c = x', 'a = b = c = None'), - + ("a or b", "a and b"), + ("a and b", "a or b"), + ("not a", "a"), + ("a < b", ["a <= b"]), + ("a <= b", ["a < b"]), + ("a > b", ["a >= b"]), + ("a >= b", ["a > b"]), + ("a == b", ["a != b"]), + ("a != b", ["a == b"]), + ("a = b", "a = None"), + ("a = b = c = x", "a = b = c = None"), # subscript - ('a[None]', []), - ('a[b]', []), - ('s[0]', ['s[1]']), - ('s[0] = a', ['s[1] = a', 's[0] = None']), - ('s[1:]', ['s[2:]']), - ('s[1:2]', ['s[2:2]', 's[1:3]']), - - ('1j', '2j'), - ('1.0j', '2j'), - ('0o1', '2'), - ('1.0e10', '10000000001.0'), - ('a = {x for x in y}', 'a = None'), - ('x+=1', ['x = 1', 'x -= 1', 'x+=2']), - ('x-=1', ['x = 1', 'x += 1', 'x-=2']), - ('x*=1', ['x = 1', 'x /= 1', 'x*=2']), - ('x/=1', ['x = 1', 'x *= 1', 'x/=2']), - ('x//=1', ['x = 1', 'x /= 1', 'x//=2']), - ('x%=1', ['x = 1', 'x /= 1', 'x%=2']), - ('x<<=1', ['x = 1', 'x >>= 1', 'x<<=2']), - ('x>>=1', ['x = 1', 'x <<= 1', 'x>>=2']), - ('x&=1', ['x = 1', 'x |= 1', 'x&=2']), - ('x|=1', ['x = 1', 'x &= 1', 'x|=2']), - ('x^=1', ['x = 1', 'x &= 1', 'x^=2']), - ('x**=1', ['x = 1', 'x *= 1', 'x**=2']), - ('def foo(s: Int = 1): pass', 'def foo(s: Int = 2): pass'), + ("a[None]", []), + ("a[b]", []), + ("s[0]", ["s[1]"]), + ("s[0] = a", ["s[1] = a", "s[0] = None"]), + ("s[1:]", ["s[2:]"]), + ("s[1:2]", ["s[2:2]", "s[1:3]"]), + ("1j", "2j"), + ("1.0j", "2j"), + ("0o1", "2"), + ("1.0e10", "10000000001.0"), + ("a = {x for x in y}", "a = None"), + ("x+=1", ["x = 1", "x -= 1", "x+=2"]), + ("x-=1", ["x = 1", "x += 1", "x-=2"]), + ("x*=1", ["x = 1", "x /= 1", "x*=2"]), + ("x/=1", ["x = 1", "x *= 1", "x/=2"]), + ("x//=1", ["x = 1", "x /= 1", "x//=2"]), + ("x%=1", ["x = 1", "x /= 1", "x%=2"]), + ("x<<=1", ["x = 1", "x >>= 1", "x<<=2"]), + ("x>>=1", ["x = 1", "x <<= 1", "x>>=2"]), + ("x&=1", ["x = 1", "x |= 1", "x&=2"]), + ("x|=1", ["x = 1", "x &= 1", "x|=2"]), + ("x^=1", ["x = 1", "x &= 1", "x^=2"]), + ("x**=1", ["x = 1", "x *= 1", "x**=2"]), + ("def foo(s: Int = 1): pass", "def foo(s: Int = 2): pass"), # mutating default args with function calls could cause Exceptions at import time ('def foo(a = A("abc")): pass', []), - ('a = None', 'a = ""'), - ('lambda **kwargs: None', 'lambda **kwargs: 0'), - ('lambda: None', 'lambda: 0'), - ('def foo(s: str): pass', []), - ('def foo(a, *, b): pass', []), - ('a(None)', []), + ("a = None", 'a = ""'), + ("lambda **kwargs: None", "lambda **kwargs: 0"), + ("lambda: None", "lambda: 0"), + ("def foo(s: str): pass", []), + ("def foo(a, *, b): pass", []), + ("a(None)", []), ("'''foo'''", []), # don't mutate things we assume to be docstrings ("r'''foo'''", []), # don't mutate things we assume to be docstrings ('"""foo"""', []), # don't mutate things we assume to be docstrings - ('(x for x in [])', []), # don't mutate 'in' in generators - ('from foo import *', []), - ('from .foo import *', []), - ('import foo', []), - ('import foo as bar', []), - ('foo.bar', []), - ('for x in y: pass', []), - ('def foo(a, *args, **kwargs): pass', []), - ('isinstance(a, b)', []), - ('len(a)', []), - ('deepcopy(obj)', ['copy(obj)', 'deepcopy(None)']), - ] + ("(x for x in [])", []), # don't mutate 'in' in generators + ("from foo import *", []), + ("from .foo import *", []), + ("import foo", []), + ("import foo as bar", []), + ("foo.bar", []), + ("for x in y: pass", []), + ("def foo(a, *args, **kwargs): pass", []), + ("isinstance(a, b)", []), + ("len(a)", []), + ("deepcopy(obj)", ["copy(obj)", "deepcopy(None)"]), + ], ) def test_basic_mutations(original, expected): if isinstance(expected, str): @@ -326,9 +402,9 @@ def test_function_with_annotation(): print(mutated_code) expected_defs = [ - 'def x_capitalize__mutmut_1(s : str):\n return s[0].title() - s[1:] if s else s', - 'def x_capitalize__mutmut_2(s : str):\n return s[1].title() + s[1:] if s else s', - 'def x_capitalize__mutmut_3(s : str):\n return s[0].title() + s[2:] if s else s', + "def x_capitalize__mutmut_1(s : str):\n return s[0].title() - s[1:] if s else s", + "def x_capitalize__mutmut_2(s : str):\n return s[1].title() + s[1:] if s else s", + "def x_capitalize__mutmut_3(s : str):\n return s[0].title() + s[2:] if s else s", ] for expected in expected_defs: @@ -371,17 +447,17 @@ def test_mutate_only_covered_lines_all(): def test_mutate_dict(): - source = 'dict(a=b, c=d)' + source = "dict(a=b, c=d)" mutants = mutants_for_source(source) expected = [ - 'dict(a=None, c=d)', - 'dict(aXX=b, c=d)', - 'dict(a=b, c=None)', - 'dict(a=b, cXX=d)', - 'dict(c=d)', - 'dict(a=b, )', + "dict(a=None, c=d)", + "dict(aXX=b, c=d)", + "dict(a=b, c=None)", + "dict(a=b, cXX=d)", + "dict(c=d)", + "dict(a=b, )", ] assert sorted(mutants) == sorted(expected) @@ -389,7 +465,7 @@ def test_mutate_dict(): def test_syntax_error(): with pytest.raises(cst.ParserSyntaxError): - mutate_file_contents('some_file.py', ':!') + mutate_file_contents("some_file.py", ":!") def test_bug_github_issue_18(): @@ -430,7 +506,7 @@ def from_checker(cls: Type['BaseVisitor'], checker) -> 'BaseVisitor': def test_bug_github_issue_77(): # Don't crash on this - assert mutants_for_source('') == [] + assert mutants_for_source("") == [] def test_bug_github_issue_435(): @@ -444,15 +520,15 @@ def parse(self, text: str) -> tuple[Tree[Token], str]: mutants = mutants_for_source(source) expected = [ - 'def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = None\n\n return self.parser.parse(text), text', - 'def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(None, dashrepl, text)\n\n return self.parser.parse(text), text', + "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = None\n\n return self.parser.parse(text), text", + "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(None, dashrepl, text)\n\n return self.parser.parse(text), text", "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', None, text)\n\n return self.parser.parse(text), text", "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', dashrepl, None)\n\n return self.parser.parse(text), text", - 'def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(dashrepl, text)\n\n return self.parser.parse(text), text', + "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(dashrepl, text)\n\n return self.parser.parse(text), text", "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', text)\n\n return self.parser.parse(text), text", "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', dashrepl, )\n\n return self.parser.parse(text), text", "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'XX[\\w\\-] [\\w\\-]XX', dashrepl, text)\n\n return self.parser.parse(text), text", - "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', dashrepl, text)\n\n return self.parser.parse(None), text" + "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', dashrepl, text)\n\n return self.parser.parse(None), text", ] assert sorted(mutants) == sorted(expected) @@ -480,51 +556,59 @@ def foo(): def test_orig_function_name_from_key(): - assert orig_function_and_class_names_from_key( - f'_{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar__mutmut_1') == ('bar', 'Foo') - assert orig_function_and_class_names_from_key('x_bar__mutmut_1') == ('bar', None) + assert orig_function_and_class_names_from_key(f"_{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar__mutmut_1") == ( + "bar", + "Foo", + ) + assert orig_function_and_class_names_from_key("x_bar__mutmut_1") == ("bar", None) def test_mangle_function_name(): - assert mangle_function_name(name='bar', class_name=None) == 'x_bar' - assert mangle_function_name(name='bar', class_name='Foo') == f'x{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar' + assert mangle_function_name(name="bar", class_name=None) == "x_bar" + assert mangle_function_name(name="bar", class_name="Foo") == f"x{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar" def test_diff_ops(): source = """ -def foo(): +def foo(): return 1 class Foo: - def member(self): + def member(self): return 3 """.strip() - mutants_source, mutant_names = mutate_file_contents('filename', source) + mutants_source, mutant_names = mutate_file_contents("filename", source) assert len(mutant_names) == 2 - diff1 = get_diff_for_mutant(mutant_name=mutant_names[0], source=mutants_source, path='test.py').strip() - diff2 = get_diff_for_mutant(mutant_name=mutant_names[1], source=mutants_source, path='test.py').strip() + diff1 = get_diff_for_mutant(mutant_name=mutant_names[0], source=mutants_source, path="test.py").strip() + diff2 = get_diff_for_mutant(mutant_name=mutant_names[1], source=mutants_source, path="test.py").strip() - assert diff1 == ''' + assert ( + diff1 + == """ --- test.py +++ test.py @@ -1,2 +1,2 @@ - def foo(): + def foo(): - return 1 + return 2 -'''.strip() +""".strip() + ) - assert diff2 == ''' + assert ( + diff2 + == """ --- test.py +++ test.py @@ -1,2 +1,2 @@ - def member(self): + def member(self): - return 3 + return 4 -'''.strip() +""".strip() + ) def test_from_future_still_first(): @@ -536,8 +620,8 @@ def foo(): return 1 """.strip() mutated_source = mutated_module(source) - assert mutated_source.split('\n')[0] == 'from __future__ import annotations' - assert mutated_source.count('from __future__') == 1 + assert mutated_source.split("\n")[0] == "from __future__ import annotations" + assert mutated_source.count("from __future__") == 1 def test_from_future_with_docstring_still_first(): @@ -550,15 +634,15 @@ def foo(): return 1 """.strip() mutated_source = mutated_module(source) - assert mutated_source.split('\n')[0] == "'''This documents the module'''" - assert mutated_source.split('\n')[1] == 'from __future__ import annotations' - assert mutated_source.count('from __future__') == 1 + assert mutated_source.split("\n")[0] == "'''This documents the module'''" + assert mutated_source.split("\n")[1] == "from __future__ import annotations" + assert mutated_source.count("from __future__") == 1 # Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions -@patch.object(CatchOutput, 'dump_output') -@patch.object(CatchOutput, 'stop') -@patch.object(CatchOutput, 'start') +@patch.object(CatchOutput, "dump_output") +@patch.object(CatchOutput, "stop") +@patch.object(CatchOutput, "start") def test_run_forced_fail_test_with_failing_test(_start, _stop, _dump_output, capfd): mutmut._reset_globals() runner = _mocked_runner_run_forced_failed(return_value=1) @@ -570,14 +654,14 @@ def test_run_forced_fail_test_with_failing_test(_start, _stop, _dump_output, cap print() print(f"out: {out}") print(f"err: {err}") - assert 'done' in out - assert not os.environ['MUTANT_UNDER_TEST'] + assert "done" in out + assert not os.environ["MUTANT_UNDER_TEST"] # Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions -@patch.object(CatchOutput, 'dump_output') -@patch.object(CatchOutput, 'stop') -@patch.object(CatchOutput, 'start') +@patch.object(CatchOutput, "dump_output") +@patch.object(CatchOutput, "stop") +@patch.object(CatchOutput, "start") def test_run_forced_fail_test_with_mutmut_programmatic_fail_exception(_start, _stop, _dump_output, capfd): mutmut._reset_globals() runner = _mocked_runner_run_forced_failed(side_effect=MutmutProgrammaticFailException()) @@ -585,14 +669,14 @@ def test_run_forced_fail_test_with_mutmut_programmatic_fail_exception(_start, _s run_forced_fail_test(runner) out, _ = capfd.readouterr() - assert 'done' in out - assert not os.environ['MUTANT_UNDER_TEST'] + assert "done" in out + assert not os.environ["MUTANT_UNDER_TEST"] # Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions -@patch.object(CatchOutput, 'dump_output') -@patch.object(CatchOutput, 'stop') -@patch.object(CatchOutput, 'start') +@patch.object(CatchOutput, "dump_output") +@patch.object(CatchOutput, "stop") +@patch.object(CatchOutput, "start") def test_run_forced_fail_test_with_all_tests_passing(_start, _stop, _dump_output, capfd): mutmut._reset_globals() runner = _mocked_runner_run_forced_failed(return_value=0) @@ -602,15 +686,12 @@ def test_run_forced_fail_test_with_all_tests_passing(_start, _stop, _dump_output assert error.value.code == 1 out, _ = capfd.readouterr() - assert 'FAILED: Unable to force test failures' in out + assert "FAILED: Unable to force test failures" in out def _mocked_runner_run_forced_failed(return_value=None, side_effect=None): runner = Mock() - runner.run_forced_fail = Mock( - return_value=return_value, - side_effect=side_effect - ) + runner.run_forced_fail = Mock(return_value=return_value, side_effect=side_effect) return runner diff --git a/tests/test_type_checking.py b/tests/test_type_checking.py index b786b04a..c0c45864 100644 --- a/tests/test_type_checking.py +++ b/tests/test_type_checking.py @@ -1,8 +1,10 @@ +from pathlib import Path + from inline_snapshot import snapshot -from mutmut.type_checking import parse_mypy_report, parse_pyrefly_report -from pathlib import Path from mutmut.type_checking import TypeCheckingError +from mutmut.type_checking import parse_mypy_report +from mutmut.type_checking import parse_pyrefly_report def test_mypy_parsing(): diff --git a/uv.lock b/uv.lock index 00c3eb67..4ac67fc3 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "click" version = "8.0.0" @@ -84,6 +93,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/d5/1bf0476b77b1466970a0d7a9982806efa3e5ab5c63f94db623c7458b97b7/coverage-7.3.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0", size = 193508, upload-time = "2023-08-12T18:35:23.999Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -105,6 +123,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "filelock" +version = "3.24.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, +] + +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -269,6 +305,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "inline-snapshot" }, + { name = "pre-commit" }, { name = "pyrefly" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -288,11 +325,21 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "inline-snapshot", specifier = ">=0.32.0" }, + { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pyrefly", specifier = ">=0.53.0" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "ruff", specifier = ">=0.15.1" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -320,6 +367,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -377,6 +440,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "python-discovery" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/93a3e83bdf9322c7e21cafd092e56a4a17c4d8ef4277b6eb01af1a540a6f/python_discovery-1.1.0.tar.gz", hash = "sha256:447941ba1aed8cc2ab7ee3cb91be5fc137c5bdbb05b7e6ea62fbdcb66e50b268", size = 55674, upload-time = "2026-02-26T09:42:49.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/54/82a6e2ef37f0f23dccac604b9585bdcbd0698604feb64807dcb72853693e/python_discovery-1.1.0-py3-none-any.whl", hash = "sha256:a162893b8809727f54594a99ad2179d2ede4bf953e12d4c7abc3cc9cdbd1437b", size = 30687, upload-time = "2026-02-26T09:42:48.548Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -505,9 +581,87 @@ wheels = [ [[package]] name = "setproctitle" -version = "1.1" +version = "1.3.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/7a/f0074ce6178bdfba69f824213527e80c6b7d3760c7bae706ca2bef6b8918/setproctitle-1.1.tar.gz", hash = "sha256:03c437f3a0e893b20a2511140625ce8e121d403f153234d1de0ea69d85b61ca5", size = 17102, upload-time = "2010-07-07T03:28:24.3Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/48/fb401ec8c4953d519d05c87feca816ad668b8258448ff60579ac7a1c1386/setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b", size = 18079, upload-time = "2025-09-05T12:49:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a3/c2b0333c2716fb3b4c9a973dd113366ac51b4f8d56b500f4f8f704b4817a/setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7", size = 13099, upload-time = "2025-09-05T12:49:09.222Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f8/17bda581c517678260e6541b600eeb67745f53596dc077174141ba2f6702/setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c", size = 31793, upload-time = "2025-09-05T12:49:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/27/d1/76a33ae80d4e788ecab9eb9b53db03e81cfc95367ec7e3fbf4989962fedd/setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3", size = 32779, upload-time = "2025-09-05T12:49:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/59/27/1a07c38121967061564f5e0884414a5ab11a783260450172d4fc68c15621/setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f", size = 34578, upload-time = "2025-09-05T12:49:13.393Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/725e6353935962d8bb12cbf7e7abba1d0d738c7f6935f90239d8e1ccf913/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64", size = 32030, upload-time = "2025-09-05T12:49:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/e4677ae8e1cb0d549ab558b12db10c175a889be0974c589c428fece5433e/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5", size = 33363, upload-time = "2025-09-05T12:49:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/55/d4/69ce66e4373a48fdbb37489f3ded476bb393e27f514968c3a69a67343ae0/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381", size = 31508, upload-time = "2025-09-05T12:49:18.032Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5a/42c1ed0e9665d068146a68326529b5686a1881c8b9197c2664db4baf6aeb/setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c", size = 12558, upload-time = "2025-09-05T12:49:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/dd206cc19a25561921456f6cb12b405635319299b6f366e0bebe872abc18/setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95", size = 13245, upload-time = "2025-09-05T12:49:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, + { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/34/8a/aff5506ce89bc3168cb492b18ba45573158d528184e8a9759a05a09088a9/setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6", size = 12654, upload-time = "2025-09-05T12:51:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/5b6f2faedd6ced3d3c085a5efbd91380fb1f61f4c12bc42acad37932f4e9/setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2", size = 14284, upload-time = "2025-09-05T12:51:18.393Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c0/4312fed3ca393a29589603fd48f17937b4ed0638b923bac75a728382e730/setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78", size = 13282, upload-time = "2025-09-05T12:51:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, +] [[package]] name = "textual" @@ -599,3 +753,19 @@ sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e wheels = [ { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, ] + +[[package]] +name = "virtualenv" +version = "21.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471, upload-time = "2026-02-27T08:49:29.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" }, +]