diff --git a/.github/actions/pysen-test-post-313/action.yml b/.github/actions/pysen-test-post-313/action.yml index 9033df0..c86c366 100644 --- a/.github/actions/pysen-test-post-313/action.yml +++ b/.github/actions/pysen-test-post-313/action.yml @@ -8,4 +8,4 @@ runs: - '-c' # Changing the ownership is required. See the following link for more details: # https://github.blog/2022-04-12-git-security-vulnerability-announced/ - - "chown -R $(id -u):$(id -g) . && tox --parallel -e 'py{313,314}-post313'" + - "chown -R $(id -u):$(id -g) . && tox --parallel -e 'py{313,314}-post313, py{313,314}-mypy2'" diff --git a/pyproject.toml b/pyproject.toml index 3a2ccd9..2a26293 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ lint = [ "flake8>=3.7,<5", "flake8-bugbear", # flake8 doesn't have a dependency for bugbear plugin "isort>=4.3,<5.2.0", - "mypy>=0.770,<0.800", + "mypy>=0.770,<3", ] [project.scripts] diff --git a/src/pysen/ext/mypy_wrapper.py b/src/pysen/ext/mypy_wrapper.py index 6c5df5f..d5d89b4 100644 --- a/src/pysen/ext/mypy_wrapper.py +++ b/src/pysen/ext/mypy_wrapper.py @@ -8,7 +8,7 @@ from pysen.command import check_command_installed from pysen.dist_version import get_version from pysen.error_lines import parse_error_lines -from pysen.exceptions import IncompatibleVersionError +from pysen.exceptions import IncompatibleVersionError, PysenError from pysen.path import PathLikeType, change_dir, get_relative_path, resolve_path from pysen.py_version import PythonVersion from pysen.reporter import Reporter @@ -191,11 +191,25 @@ class MypyTarget: @functools.lru_cache(1) def _check_mypy_version() -> None: version = get_version("mypy") - if version.major >= 2 or version.major == 0 and version.minor < 770: + if version.major >= 3 or version.major == 0 and version.minor < 770: raise IncompatibleVersionError( - f"pysen only supports mypy version >=0.770, <2. " + f"pysen only supports mypy version >=0.770, <3. " f"version {version} is not supported." ) + if version.major >= 2: + # mypy >=2 uses a sqlite-backed cache by default, which requires the + # stdlib sqlite3 module (and the underlying libsqlite3 shared library) + # to be importable. Fail early with an actionable message instead of + # letting mypy crash with an INTERNAL ERROR. + try: + import sqlite3 # noqa: F401 + except ImportError as e: + raise PysenError( + f"mypy {version} requires the sqlite3 module for its cache, " + f"but it could not be imported ({e}). Please install sqlite " + f"(e.g. the libsqlite3 / sqlite-libs system package) and " + f"rebuild Python with sqlite3 support." + ) from e def run( diff --git a/tests/test_mypy_wrapper.py b/tests/test_mypy_wrapper.py index f5378b0..d884fd0 100644 --- a/tests/test_mypy_wrapper.py +++ b/tests/test_mypy_wrapper.py @@ -1,8 +1,11 @@ import pathlib +import sys +from unittest import mock import pytest -from pysen.ext.mypy_wrapper import MypyPlugin, MypyTarget, run +from pysen.exceptions import IncompatibleVersionError, PysenError +from pysen.ext.mypy_wrapper import MypyPlugin, MypyTarget, _check_mypy_version, run from pysen.reporter import Reporter BASE_DIR = pathlib.Path(__file__).resolve().parent @@ -13,6 +16,51 @@ def test_run_zero_source() -> None: assert run(reporter, BASE_DIR, BASE_DIR, MypyTarget([]), True) == 0 +def test__check_mypy_version() -> None: + def check(version: str) -> None: + _check_mypy_version.cache_clear() + with mock.patch( + "pysen.dist_version.distribution", + return_value=mock.Mock(version=version), + ): + # decouple the version-logic check from the host's sqlite3 + # availability; the sqlite3 requirement for mypy >=2 is covered by + # test__check_mypy_version_requires_sqlite_for_mypy2 + with mock.patch.dict(sys.modules, {"sqlite3": mock.Mock()}): + _check_mypy_version() + + # supported: >=0.770, <3 + check("0.770") + check("0.991") + check("1.19.0") + check("2.0.0") + check("2.1.0") + + # unsupported + for version in ("0.760", "3.0.0"): + with pytest.raises(IncompatibleVersionError): + check(version) + + +def test__check_mypy_version_requires_sqlite_for_mypy2() -> None: + def check(version: str) -> None: + _check_mypy_version.cache_clear() + with mock.patch( + "pysen.dist_version.distribution", + return_value=mock.Mock(version=version), + ): + # setting the entry to None makes `import sqlite3` raise ImportError + with mock.patch.dict(sys.modules, {"sqlite3": None}): + _check_mypy_version() + + # mypy >=2 requires sqlite3 + with pytest.raises(PysenError, match="sqlite3"): + check("2.0.0") + + # mypy <2 does not need sqlite3 + check("1.19.0") + + def test_mypy_plugin() -> None: script_plugin = MypyPlugin(script=pathlib.Path("/foo/bar/baz")) script_plugin2 = MypyPlugin(script=pathlib.Path("./bar/baz")) diff --git a/tox.ini b/tox.ini index e29b256..2fbbbf0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{310}-dacite150-isort51-black{20,22}-mypy078, py{311,312}-dacite150-isort51-black22-mypy099, development, latest, py{313,314}-post313 +envlist = py{310}-dacite150-isort51-black{20,22}-mypy078, py{311,312}-dacite150-isort51-black22-mypy099, development, latest, py{313,314}-post313, py{313,314}-mypy2 isolated_build = True [testenv] @@ -26,7 +26,14 @@ setenv = basepython = python3.10 deps = pipenv==2022.9.8 + # pipenv 2022.9.8 imports pkg_resources, which was removed in setuptools + # >=81; current virtualenv seeds a newer setuptools, so pin it back. + setuptools<81 extras = lint +setenv = + LANG = C + # isolate pipenv's cache per env; the shared default races under --parallel + PIPENV_CACHE_DIR = {envdir}/.pipenv-cache commands = pipenv sync --system pip install . @@ -37,7 +44,14 @@ commands = basepython = python3.10 deps = pipenv==2022.9.8 + # pipenv 2022.9.8 imports pkg_resources, which was removed in setuptools + # >=81; current virtualenv seeds a newer setuptools, so pin it back. + setuptools<81 extras = lint +setenv = + LANG = C + # isolate pipenv's cache per env; the shared default races under --parallel + PIPENV_CACHE_DIR = {envdir}/.pipenv-cache commands = pipenv sync --system pip install . @@ -57,3 +71,17 @@ changedir = version_testing/post_313 commands = pip install ../../ pysen run lint + +[testenv:py{313,314}-mypy2] +deps = + pipenv==2026.0.2 + mypy>=2.0,<3 + isort==7.0.0 + black==25.12.0 + flake8==7.3.0 + flake8-bugbear==25.11.29 + importlib-metadata==8.7.0 +changedir = version_testing/post_mypy2 +commands = + pip install ../../ + pysen run lint diff --git a/version_testing/README.md b/version_testing/README.md index 6912b68..024b31b 100644 --- a/version_testing/README.md +++ b/version_testing/README.md @@ -3,4 +3,20 @@ ```bash $ docker build -t quay.io/pysen/pysen-test . $ docker push quay.io/pysen/pysen-test -``` \ No newline at end of file +``` + +## Directories + +- `post_310` / `post_313`: smoke-test projects exercised by the corresponding + `tox` environments for newer Python interpreters. +- `post_mypy2`: smoke-test project for `mypy >=2` (`py{313,314}-mypy2` tox env). + +### Note on mypy >=2 and `py_version` + +mypy `>=2` only supports type-checking against `python_version >= 3.10`. When a +pysen config sets an older `py_version` (e.g. `py27`, `py38`, `py39`), pysen +still emits `python_version = 3.x` into the mypy config. mypy `>=2` prints a +warning (`python_version: Python 3.8 is not supported (must be 3.10 or higher)`) +and silently falls back to its minimum supported version, so the effective +type-checking semantics differ from what the user requested. The `post_mypy2` +example therefore uses `py_version = "py313"`. diff --git a/version_testing/post_313/Dockerfile b/version_testing/post_313/Dockerfile index 4e03eb9..0e11026 100644 --- a/version_testing/post_313/Dockerfile +++ b/version_testing/post_313/Dockerfile @@ -3,6 +3,8 @@ FROM alpine:3.23 COPY --from=python:3.13.11-alpine3.23 /usr/local/ /usr/local/ COPY --from=python:3.14.2-alpine3.23 /usr/local/ /usr/local/ -RUN apk add --no-cache bash git \ +# sqlite-libs is required by mypy >=2, whose default cache backend uses sqlite3 +# (mypy <2 used a filesystem metastore and did not need it). +RUN apk add --no-cache bash git sqlite-libs \ && pip3.14 install --no-cache-dir tox==3.28.0 ENV TOX_PARALLEL_NO_SPINNER=1 \ No newline at end of file diff --git a/version_testing/post_mypy2/pyproject.toml b/version_testing/post_mypy2/pyproject.toml new file mode 100644 index 0000000..1f707df --- /dev/null +++ b/version_testing/post_mypy2/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "example_post_mypy2" +requires-python = ">=3.13" +description = "example project for mypy version >=2" + +[tool.pysen] +version = "0.12" + +[tool.pysen.lint] +enable_black = true +enable_flake8 = true +enable_isort = true +enable_mypy = true +mypy_preset = "very_strict" +# NOTE: mypy >=2 only supports checking python_version >=3.10. Older py_version +# values (py27/py38/py39) are rejected by mypy and silently fall back to its +# minimum supported version. See version_testing/README.md. +py_version = "py313" +[[tool.pysen.lint.mypy_targets]] + paths = ["src"] diff --git a/version_testing/post_mypy2/src/example_code.py b/version_testing/post_mypy2/src/example_code.py new file mode 100644 index 0000000..4acb92e --- /dev/null +++ b/version_testing/post_mypy2/src/example_code.py @@ -0,0 +1,2 @@ +def main(arg1: str, arg2: int) -> bool: + return arg1.isdigit() and arg2 > 0