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

Run static type checking with Mypy #115

Merged
merged 9 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jobs:
"ubuntu-py310",
"ubuntu-py311",
"ubuntu-py312",

"mypy",
]

include:
Expand Down Expand Up @@ -99,6 +101,11 @@ jobs:
os: ubuntu-latest
tox_env: "py312"

- name: "mypy"
python: "3.12"
os: ubuntu-latest
tox_env: "mypy"

steps:
- uses: actions/checkout@v3

Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,10 @@ requires = [
]
build-backend = "setuptools.build_meta"

[tool.mypy]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be properly usable for externals we need to be closer to strict than the defaults

i haven't yet take an look how much pain is in that change but i believe we need to disallow untyped defs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disallow_untyped_defs = true
warn_unreachable = true
warn_unused_configs = true
warn_unused_ignores = true

[tool.setuptools_scm]
13 changes: 10 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,27 @@ classifiers =
Programming Language :: Python :: Implementation :: CPython
Operating System :: OS Independent
License :: OSI Approved :: MIT License
Typing :: Typed
keywords = test, unittest, pytest

[options]
py_modules = pytest_subtests
install_requires =
pytest>=7.0
attrs>=19.2.0
nicoddemus marked this conversation as resolved.
Show resolved Hide resolved
python_requires = >=3.7
packages = find:
package_dir =
=src
= src
setup_requires =
setuptools
setuptools-scm>=6.0

[options.packages.find]
where = src

[options.entry_points]
pytest11 =
subtests = pytest_subtests
subtests = pytest_subtests.plugin

[options.package_data]
pytest_subtests = py.typed
Empty file added src/pytest_subtests/__init__.py
Empty file.
115 changes: 79 additions & 36 deletions src/pytest_subtests.py → src/pytest_subtests/plugin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
from __future__ import annotations

import sys
import time
from contextlib import contextmanager
from contextlib import nullcontext
from typing import Any
from typing import Callable
from typing import ContextManager
from typing import Generator
from typing import Mapping
from typing import TYPE_CHECKING
from unittest import TestCase

import attr
import pytest
from _pytest._code import ExceptionInfo
from _pytest.capture import CaptureFixture
from _pytest.capture import FDCapture
from _pytest.capture import SysCapture
from _pytest.config.compat import PathAwareHookProxy
from _pytest.fixtures import SubRequest
from _pytest.logging import catching_logs
from _pytest.logging import LogCaptureHandler
from _pytest.outcomes import OutcomeException
Expand All @@ -16,8 +28,16 @@
from _pytest.runner import check_interactive_exception
from _pytest.unittest import TestCaseFunction

if TYPE_CHECKING:
from types import TracebackType

if sys.version_info < (3, 8):
from typing_extensions import Literal
else:
from typing import Literal


def pytest_addoption(parser):
def pytest_addoption(parser: pytest.Parser) -> None:
group = parser.getgroup("subtests")
group.addoption(
"--no-subtests-shortletter",
Expand All @@ -30,20 +50,20 @@ def pytest_addoption(parser):

@attr.s
class SubTestContext:
msg = attr.ib()
kwargs = attr.ib()
msg: str | None = attr.ib()
kwargs: dict[str, Any] = attr.ib()


@attr.s(init=False)
class SubTestReport(TestReport):
context = attr.ib()
class SubTestReport(TestReport): # type: ignore[misc]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note - pytest core switched to dataclasses, so we might need to investigate the details here to avoid mixing

context: SubTestContext = attr.ib()

@property
def head_line(self):
def head_line(self) -> str:
_, _, domain = self.location
return f"{domain} {self.sub_test_description()}"

def sub_test_description(self):
def sub_test_description(self) -> str:
parts = []
if isinstance(self.context.msg, str):
parts.append(f"[{self.context.msg}]")
Expand All @@ -54,15 +74,15 @@ def sub_test_description(self):
parts.append(f"({params_desc})")
return " ".join(parts) or "(<subtest>)"

def _to_json(self):
def _to_json(self) -> dict:
data = super()._to_json()
del data["context"]
data["_report_type"] = "SubTestReport"
data["_subtest.context"] = attr.asdict(self.context)
return data

@classmethod
def _from_json(cls, reportdict):
def _from_json(cls, reportdict: dict) -> SubTestReport:
edgarrmondragon marked this conversation as resolved.
Show resolved Hide resolved
report = super()._from_json(reportdict)
context_data = reportdict["_subtest.context"]
report.context = SubTestContext(
Expand All @@ -71,13 +91,18 @@ def _from_json(cls, reportdict):
return report

@classmethod
def _from_test_report(cls, test_report):
def _from_test_report(cls, test_report: TestReport) -> SubTestReport:
return super()._from_json(test_report._to_json())


def _addSubTest(self, test_case, test, exc_info):
def _addSubTest(
self: TestCaseFunction,
test_case: Any,
test: TestCase,
exc_info: tuple[type[BaseException], BaseException, TracebackType] | None,
) -> None:
if exc_info is not None:
msg = test._message if isinstance(test._message, str) else None
msg = test._message if isinstance(test._message, str) else None # type: ignore[attr-defined]
call_info = make_call_info(
ExceptionInfo(exc_info, _ispytest=True),
start=0,
Expand All @@ -87,17 +112,17 @@ def _addSubTest(self, test_case, test, exc_info):
)
report = self.ihook.pytest_runtest_makereport(item=self, call=call_info)
sub_report = SubTestReport._from_test_report(report)
sub_report.context = SubTestContext(msg, dict(test.params))
sub_report.context = SubTestContext(msg, dict(test.params)) # type: ignore[attr-defined]
self.ihook.pytest_runtest_logreport(report=sub_report)
if check_interactive_exception(call_info, sub_report):
self.ihook.pytest_exception_interact(
node=self, call=call_info, report=sub_report
)


def pytest_configure(config):
TestCaseFunction.addSubTest = _addSubTest
TestCaseFunction.failfast = False
def pytest_configure(config: pytest.Config) -> None:
TestCaseFunction.addSubTest = _addSubTest # type: ignore[attr-defined]
TestCaseFunction.failfast = False # type: ignore[attr-defined]

# Hack (#86): the terminal does not know about the "subtests"
# status, so it will by default turn the output to yellow.
Expand All @@ -110,7 +135,7 @@ def pytest_configure(config):
# We need to check if we are not re-adding because we run our own tests
# with pytester in-process mode, so this will be called multiple times.
if new_types[0] not in _pytest.terminal.KNOWN_TYPES:
_pytest.terminal.KNOWN_TYPES = _pytest.terminal.KNOWN_TYPES + new_types
_pytest.terminal.KNOWN_TYPES = _pytest.terminal.KNOWN_TYPES + new_types # type: ignore[assignment]

_pytest.terminal._color_for_type.update(
{
Expand All @@ -121,15 +146,15 @@ def pytest_configure(config):
)


def pytest_unconfigure():
def pytest_unconfigure() -> None:
if hasattr(TestCaseFunction, "addSubTest"):
del TestCaseFunction.addSubTest
if hasattr(TestCaseFunction, "failfast"):
del TestCaseFunction.failfast


@pytest.fixture
def subtests(request):
def subtests(request: SubRequest) -> Generator[SubTests, None, None]:
capmam = request.node.config.pluginmanager.get_plugin("capturemanager")
if capmam is not None:
suspend_capture_ctx = capmam.global_and_fixture_disabled
Expand All @@ -140,16 +165,16 @@ def subtests(request):

@attr.s
class SubTests:
ihook = attr.ib()
suspend_capture_ctx = attr.ib()
request = attr.ib()
ihook: PathAwareHookProxy = attr.ib()
suspend_capture_ctx: Callable[[], ContextManager] = attr.ib()
request: SubRequest = attr.ib()

@property
def item(self):
def item(self) -> pytest.Item:
return self.request.node

@contextmanager
def _capturing_output(self):
def _capturing_output(self) -> Generator[Captured, None, None]:
option = self.request.config.getoption("capture", None)

# capsys or capfd are active, subtest should not capture
Expand Down Expand Up @@ -180,7 +205,7 @@ def _capturing_output(self):
captured.err = err

@contextmanager
def _capturing_logs(self):
def _capturing_logs(self) -> Generator[CapturedLogs | NullCapturedLogs, None, None]:
logging_plugin = self.request.config.pluginmanager.getplugin("logging-plugin")
if logging_plugin is None:
yield NullCapturedLogs()
Expand All @@ -193,7 +218,11 @@ def _capturing_logs(self):
yield captured_logs

@contextmanager
def test(self, msg=None, **kwargs):
def test(
self,
msg: str | None = None,
**kwargs: Any,
) -> Generator[None, None, None]:
start = time.time()
precise_start = time.perf_counter()
exc_info = None
Expand Down Expand Up @@ -227,7 +256,14 @@ def test(self, msg=None, **kwargs):
)


def make_call_info(exc_info, *, start, stop, duration, when):
def make_call_info(
exc_info: ExceptionInfo[BaseException] | None,
*,
start: float,
stop: float,
duration: float,
when: Literal["collect", "setup", "call", "teardown"],
) -> CallInfo:
return CallInfo(
None,
exc_info,
Expand All @@ -240,7 +276,7 @@ def make_call_info(exc_info, *, start, stop, duration, when):


@contextmanager
def ignore_pytest_private_warning():
def ignore_pytest_private_warning() -> Generator[None, None, None]:
import warnings

with warnings.catch_warnings():
Expand All @@ -257,40 +293,45 @@ class Captured:
out = attr.ib(default="", type=str)
err = attr.ib(default="", type=str)

def update_report(self, report):
def update_report(self, report: pytest.TestReport) -> None:
if self.out:
report.sections.append(("Captured stdout call", self.out))
if self.err:
report.sections.append(("Captured stderr call", self.err))


class CapturedLogs:
def __init__(self, handler):
def __init__(self, handler: LogCaptureHandler) -> None:
self._handler = handler

def update_report(self, report):
def update_report(self, report: pytest.TestReport) -> None:
report.sections.append(("Captured log call", self._handler.stream.getvalue()))


class NullCapturedLogs:
def update_report(self, report):
def update_report(self, report: pytest.TestReport) -> None:
pass


def pytest_report_to_serializable(report):
def pytest_report_to_serializable(report: pytest.TestReport) -> dict[str, Any] | None:
if isinstance(report, SubTestReport):
return report._to_json()
return None


def pytest_report_from_serializable(data):
def pytest_report_from_serializable(data: dict[str, Any]) -> SubTestReport | None:
if data.get("_report_type") == "SubTestReport":
return SubTestReport._from_json(data)
return None


@pytest.hookimpl(tryfirst=True)
def pytest_report_teststatus(report, config):
def pytest_report_teststatus(
report: pytest.TestReport,
config: pytest.Config,
) -> tuple[str, str, str | Mapping[str, bool]] | None:
if report.when != "call" or not isinstance(report, SubTestReport):
return
return None

if hasattr(report, "wasxfail"):
return None
Expand All @@ -306,3 +347,5 @@ def pytest_report_teststatus(report, config):
elif outcome == "failed":
short = "" if config.option.no_subtests_shortletter else "u"
return outcome, short, f"{description} SUBFAIL"

return None
Empty file added src/pytest_subtests/py.typed
Empty file.
8 changes: 7 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = py37,py38,py39,py310,py311,py312
envlist = py37,py38,py39,py310,py311,py312,mypy

[testenv]
passenv =
Expand All @@ -12,3 +12,9 @@ deps =

commands =
pytest {posargs:tests}

[testenv:mypy]
deps =
mypy
pytest
commands = mypy src