From 3fcabf10b810c8585b858fb81fc3cd8c5efe898d Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 25 Aug 2024 13:26:38 -0400 Subject: [PATCH 1/6] Move overload-overlap disablement to its own line for easier diffs and simpler relevant comments. Ref #142 --- mypy.ini | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mypy.ini b/mypy.ini index 83b0d15..2806c33 100644 --- a/mypy.ini +++ b/mypy.ini @@ -10,5 +10,6 @@ enable_error_code = ignore-without-code # Support namespace packages per https://github.com/python/mypy/issues/14057 explicit_package_bases = True -# Disable overload-overlap due to many false-positives -disable_error_code = overload-overlap +disable_error_code = + # Disable due to many false positives + overload-overlap From 258516263d2e53ca9abd2252f942e7deea7528c0 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 27 Aug 2024 20:41:59 -0400 Subject: [PATCH 2/6] Strict typing and py.typed --- docs/conf.py | 9 ++++++ mypy.ini | 6 +++- pyproject.toml | 4 --- pytest_enabler/__init__.py | 64 +++++++++++++++++++++++++++++--------- pytest_enabler/py.typed | 0 tests/test_enabler.py | 21 +++++++------ 6 files changed, 75 insertions(+), 29 deletions(-) create mode 100644 pytest_enabler/py.typed diff --git a/docs/conf.py b/docs/conf.py index 3215048..d5fb468 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,6 +30,7 @@ # Be strict about any broken references nitpicky = True +nitpick_ignore = [] # Include Python intersphinx mapping to prevent failures # jaraco/skeleton#51 @@ -40,3 +41,11 @@ # Preserve authored syntax for defaults autodoc_preserve_defaults = True + +# jaraco/pytest-enabler#18 +nitpick_ignore += [ + ('py:class', 'pytest_enabler._T'), + ('py:class', '_pytest.config.Config'), + ('py:class', '_pytest.config.argparsing.Parser'), + ('py:class', 'SupportsRead'), +] 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') From b8b0578f0dbacea809f9bd99fff30d0d340e9d15 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 29 Aug 2024 12:55:09 -0400 Subject: [PATCH 3/6] Use more concise imperative voice. --- newsfragments/18.feature.rst | 1 + pytest_enabler/__init__.py | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 newsfragments/18.feature.rst diff --git a/newsfragments/18.feature.rst b/newsfragments/18.feature.rst new file mode 100644 index 0000000..88e9b7c --- /dev/null +++ b/newsfragments/18.feature.rst @@ -0,0 +1 @@ +Complete annotations and add ``py.typed`` marker -- by :user:`Avasam` diff --git a/pytest_enabler/__init__.py b/pytest_enabler/__init__.py index 2ec7588..b7c5ca1 100644 --- a/pytest_enabler/__init__.py +++ b/pytest_enabler/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import os import pathlib import re @@ -30,7 +31,7 @@ _T = TypeVar("_T") -consume = tuple # type: ignore[type-arg] # Generic doesn't matter here and we need to keep it callable +consume = tuple # type: ignore[type-arg] # Generic doesn't matter; keep it callable """ Consume an iterable """ @@ -133,9 +134,7 @@ def _pytest_cov_check( raise ValueError("parser cannot be None if cov in plugins") parser.parse_known_and_unknown_args(args, early_config.known_args_namespace) - try: + with contextlib.suppress(ImportError): import pytest_cov.plugin - except ImportError: - pass pytest_cov.plugin.pytest_load_initial_conftests(early_config, parser, args) From 0c326f3f77b2420163f73d97f8fbd090fa49147d Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 29 Aug 2024 13:13:06 -0400 Subject: [PATCH 4/6] Add a degenerate nitpick_ignore for downstream consumers. Add a 'local' comment to delineate where the skeleton ends and the downstream begins. --- docs/conf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 3215048..3d956a8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + extensions = [ 'sphinx.ext.autodoc', 'jaraco.packaging.sphinx', @@ -30,6 +33,7 @@ # Be strict about any broken references nitpicky = True +nitpick_ignore: list[tuple[str, str]] = [] # Include Python intersphinx mapping to prevent failures # jaraco/skeleton#51 @@ -40,3 +44,5 @@ # Preserve authored syntax for defaults autodoc_preserve_defaults = True + +# local From 208a16ed6b56e788939c23370c98a9b66842d7bf Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 29 Aug 2024 13:21:41 -0400 Subject: [PATCH 5/6] Mark code as uncovered. Ref jaraco/skeleton#130 --- pytest_enabler/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_enabler/__init__.py b/pytest_enabler/__init__.py index b7c5ca1..bc8f10f 100644 --- a/pytest_enabler/__init__.py +++ b/pytest_enabler/__init__.py @@ -21,7 +21,7 @@ if sys.version_info >= (3, 9): from importlib.abc import Traversable -else: +else: # pragma: no cover from pathlib import Path as Traversable if TYPE_CHECKING: From ee9dfbf7b8e4847b5112c22d2716017091e972ab Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 29 Aug 2024 12:55:09 -0400 Subject: [PATCH 6/6] Use more concise imperative voice. --- newsfragments/18.feature.rst | 1 + pytest_enabler/__init__.py | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 newsfragments/18.feature.rst diff --git a/newsfragments/18.feature.rst b/newsfragments/18.feature.rst new file mode 100644 index 0000000..88e9b7c --- /dev/null +++ b/newsfragments/18.feature.rst @@ -0,0 +1 @@ +Complete annotations and add ``py.typed`` marker -- by :user:`Avasam` diff --git a/pytest_enabler/__init__.py b/pytest_enabler/__init__.py index 2ec7588..599c473 100644 --- a/pytest_enabler/__init__.py +++ b/pytest_enabler/__init__.py @@ -1,12 +1,13 @@ from __future__ import annotations +import contextlib import os import pathlib import re import shlex import sys from collections.abc import Container, MutableSequence, Sequence -from typing import TYPE_CHECKING, TypeVar, overload +from typing import TYPE_CHECKING, TypeVar, cast, overload import toml from jaraco.context import suppress @@ -30,7 +31,7 @@ _T = TypeVar("_T") -consume = tuple # type: ignore[type-arg] # Generic doesn't matter here and we need to keep it callable +consume = tuple # type: ignore[type-arg] # Generic doesn't matter; keep it callable """ Consume an iterable """ @@ -83,7 +84,14 @@ def _has_plugin(name: str) -> bool: enabled = {key: plugins[key] for key in plugins if _has_plugin(key)} for plugin in enabled.values(): args.extend(shlex.split(plugin.get('addopts', ""))) - _pytest_cov_check(enabled, early_config, parser, args) + _pytest_cov_check( + enabled, + early_config, + # parser is only used when known not to be None + # based on `enabled` and `early_config`. + cast(Parser, parser), + args, + ) def _remove_deps() -> None: @@ -110,7 +118,7 @@ def _remove_deps() -> None: def _pytest_cov_check( plugins: Container[str], early_config: Config, - parser: Parser | None, + parser: Parser, args: Sequence[str | os.PathLike[str]], ) -> None: # pragma: nocover """ @@ -129,13 +137,9 @@ def _pytest_cov_check( _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) - try: + with contextlib.suppress(ImportError): import pytest_cov.plugin - except ImportError: - pass pytest_cov.plugin.pytest_load_initial_conftests(early_config, parser, args)