diff --git a/mypy.ini b/mypy.ini index 83b0d15..b0fdbad 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,6 @@ [mypy] # Is the project well-typed? -strict = False +strict = True # Early opt-in even when strict = False warn_unused_ignores = True @@ -12,3 +12,7 @@ explicit_package_bases = True # Disable overload-overlap due to many false-positives disable_error_code = overload-overlap + +# TODO: Raise issue upstream +[mypy-pytest_cov.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index 77a47cb..e5f10c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,3 @@ pytest11 = {enabler = "pytest_enabler"} [tool.setuptools_scm] - - -[tool.pytest-enabler.mypy] -# Disabled due to jaraco/skeleton#143 diff --git a/pytest_enabler/__init__.py b/pytest_enabler/__init__.py index f5a7b54..2ec7588 100644 --- a/pytest_enabler/__init__.py +++ b/pytest_enabler/__init__.py @@ -1,27 +1,46 @@ -import contextlib +from __future__ import annotations + +import os import pathlib import re import shlex import sys +from collections.abc import Container, MutableSequence, Sequence +from typing import TYPE_CHECKING, TypeVar, overload + +import toml +from jaraco.context import suppress +from jaraco.functools import apply +from pytest import Config, Parser -if sys.version_info > (3, 12): +if sys.version_info >= (3, 12): from importlib import resources else: import importlib_resources as resources +if sys.version_info >= (3, 9): + from importlib.abc import Traversable +else: + from pathlib import Path as Traversable + +if TYPE_CHECKING: + from _typeshed import SupportsRead + from typing_extensions import Never -import toml -from jaraco.context import suppress -from jaraco.functools import apply +_T = TypeVar("_T") -consume = tuple +consume = tuple # type: ignore[type-arg] # Generic doesn't matter here and we need to keep it callable """ Consume an iterable """ -def none_as_empty(ob): +@overload +def none_as_empty(ob: None) -> dict[Never, Never]: ... +@overload +def none_as_empty(ob: _T) -> _T: ... +def none_as_empty(ob: _T | None) -> _T | dict[Never, Never]: """ >>> none_as_empty({}) {} @@ -33,25 +52,31 @@ def none_as_empty(ob): return ob or {} -def read_plugins_stream(stream): +def read_plugins_stream( + stream: str | bytes | pathlib.PurePath | SupportsRead[str], +) -> dict[str, dict[str, str]]: defn = toml.load(stream) - return defn["tool"]["pytest-enabler"] + return defn["tool"]["pytest-enabler"] # type: ignore[no-any-return] @apply(none_as_empty) @suppress(Exception) -def read_plugins(path): +def read_plugins(path: Traversable) -> dict[str, dict[str, str]]: with path.open(encoding='utf-8') as stream: return read_plugins_stream(stream) -def pytest_load_initial_conftests(early_config, parser, args): +def pytest_load_initial_conftests( + early_config: Config, + parser: Parser | None, + args: MutableSequence[str], +) -> None: plugins = { **read_plugins(resources.files().joinpath('default.toml')), **read_plugins(pathlib.Path('pyproject.toml')), } - def _has_plugin(name): + def _has_plugin(name: str) -> bool: pm = early_config.pluginmanager return pm.has_plugin(name) or pm.has_plugin('pytest_' + name) @@ -61,7 +86,7 @@ def _has_plugin(name): _pytest_cov_check(enabled, early_config, parser, args) -def _remove_deps(): +def _remove_deps() -> None: """ Coverage will not detect function definitions as being covered if the functions are defined before coverage is invoked. As @@ -82,7 +107,12 @@ def _remove_deps(): consume(map(sys.modules.__delitem__, to_delete)) -def _pytest_cov_check(plugins, early_config, parser, args): # pragma: nocover +def _pytest_cov_check( + plugins: Container[str], + early_config: Config, + parser: Parser | None, + args: Sequence[str | os.PathLike[str]], +) -> None: # pragma: nocover """ pytest_cov runs its command-line checks so early that no hooks can intervene. By now, the hook that installs the plugin has @@ -99,9 +129,13 @@ def _pytest_cov_check(plugins, early_config, parser, args): # pragma: nocover _remove_deps() # important: parse all known args to ensure pytest-cov can configure # itself based on other plugins like pytest-xdist (see #1). + if parser is None: + raise ValueError("parser cannot be None if cov in plugins") parser.parse_known_and_unknown_args(args, early_config.known_args_namespace) - with contextlib.suppress(ImportError): + try: import pytest_cov.plugin + except ImportError: + pass pytest_cov.plugin.pytest_load_initial_conftests(early_config, parser, args) diff --git a/pytest_enabler/py.typed b/pytest_enabler/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_enabler.py b/tests/test_enabler.py index ea928f3..891e981 100644 --- a/tests/test_enabler.py +++ b/tests/test_enabler.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import subprocess import sys +from pathlib import Path from unittest import mock import pytest @@ -8,44 +11,44 @@ @pytest.fixture -def pyproject(monkeypatch, tmp_path): +def pyproject(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: monkeypatch.chdir(tmp_path) return tmp_path / 'pyproject.toml' -def test_pytest_addoption_default(): +def test_pytest_addoption_default() -> None: config = mock.MagicMock() config.pluginmanager.has_plugin = lambda name: name == 'black' - args = [] + args: list[str] = [] enabler.pytest_load_initial_conftests(config, None, args) assert args == ['--black'] -def test_pytest_addoption_override(pyproject): +def test_pytest_addoption_override(pyproject: Path) -> None: pyproject.write_text( '[tool.pytest-enabler.black]\naddopts="--black2"\n', encoding='utf-8', ) config = mock.MagicMock() config.pluginmanager.has_plugin = lambda name: name == 'black' - args = [] + args: list[str] = [] enabler.pytest_load_initial_conftests(config, None, args) assert args == ['--black2'] -def test_pytest_addoption_disable(pyproject): +def test_pytest_addoption_disable(pyproject: Path) -> None: pyproject.write_text( '[tool.pytest-enabler.black]\n#addopts="--black"\n', encoding='utf-8', ) config = mock.MagicMock() config.pluginmanager.has_plugin = lambda name: name == 'black' - args = [] + args: list[str] = [] enabler.pytest_load_initial_conftests(config, None, args) assert args == [] -def test_remove_deps(monkeypatch): +def test_remove_deps(monkeypatch: pytest.MonkeyPatch) -> None: """ Invoke _remove_deps to push coverage. """ @@ -53,7 +56,7 @@ def test_remove_deps(monkeypatch): enabler._remove_deps() -def test_coverage_explicit(tmp_path, monkeypatch): +def test_coverage_explicit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) test = tmp_path.joinpath('test_x.py') test.write_text('def test_x():\n pass\n', encoding='utf-8')