Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Strict typing and py.typed #18

Merged
merged 8 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from __future__ import annotations


extensions = [
'sphinx.ext.autodoc',
'jaraco.packaging.sphinx',
Expand Down Expand Up @@ -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
Expand All @@ -40,3 +44,13 @@

# Preserve authored syntax for defaults
autodoc_preserve_defaults = True

# local

# 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'),
]
11 changes: 8 additions & 3 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,5 +10,10 @@ 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

# TODO: Raise issue upstream
[mypy-pytest_cov.*]
ignore_missing_imports = True
1 change: 1 addition & 0 deletions newsfragments/18.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Complete annotations and add ``py.typed`` marker -- by :user:`Avasam`
4 changes: 0 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,3 @@ pytest11 = {enabler = "pytest_enabler"}


[tool.setuptools_scm]


[tool.pytest-enabler.mypy]
# Disabled due to jaraco/skeleton#143
66 changes: 52 additions & 14 deletions pytest_enabler/__init__.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,47 @@
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, cast, 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: # pragma: no cover
from pathlib import Path as Traversable

import toml
from jaraco.context import suppress
from jaraco.functools import apply
if TYPE_CHECKING:
from _typeshed import SupportsRead
from typing_extensions import Never


consume = tuple
_T = TypeVar("_T")

consume = tuple # type: ignore[type-arg] # Generic doesn't matter; 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({})
{}
Expand All @@ -33,35 +53,48 @@ 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)

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():
def _remove_deps() -> None:
"""
Coverage will not detect function definitions as being covered
if the functions are defined before coverage is invoked. As
Expand All @@ -82,7 +115,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,
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
Expand Down
Empty file added pytest_enabler/py.typed
Empty file.
21 changes: 12 additions & 9 deletions tests/test_enabler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import subprocess
import sys
from pathlib import Path
from unittest import mock

import pytest
Expand All @@ -8,52 +11,52 @@


@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.
"""
monkeypatch.setattr(sys, 'modules', dict(sys.modules))
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')
Expand Down
Loading