Run type checks only against packaged *.py files

- for tests/ and the noxfile.py, type annotations are not strictly
  enforced any more
  + this simplifies the way test cases and nox sessions are written
  + for many pytest fixtures, no types are available via a public API
- put fixtures inside the classes the corresponding test cases are
  grouped in
This commit is contained in:
Alexander Hess 2020-08-04 22:57:55 +02:00
parent 97d714d9ee
commit 8586db58c7
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
4 changed files with 57 additions and 64 deletions

View file

@ -3,7 +3,6 @@
import contextlib import contextlib
import os import os
import tempfile import tempfile
from typing import Any, Generator
import nox import nox
from nox.sessions import Session from nox.sessions import Session
@ -14,11 +13,14 @@ MAIN_PYTHON = '3.8'
# Keep the project is forward compatible. # Keep the project is forward compatible.
NEXT_PYTHON = '3.9' NEXT_PYTHON = '3.9'
# Path to the *.py files to be packaged.
PACKAGE_SOURCE_LOCATION = 'src/'
# Path to the test suite. # Path to the test suite.
PYTEST_LOCATION = 'tests/' PYTEST_LOCATION = 'tests/'
# Paths with *.py files. # Paths with all *.py files.
SRC_LOCATIONS = 'noxfile.py', 'src/', PYTEST_LOCATION SRC_LOCATIONS = 'noxfile.py', PACKAGE_SOURCE_LOCATION, PYTEST_LOCATION
# Use a unified .cache/ folder for all tools. # Use a unified .cache/ folder for all tools.
@ -38,7 +40,7 @@ nox.options.sessions = (
@nox.session(name='format', python=MAIN_PYTHON) @nox.session(name='format', python=MAIN_PYTHON)
def format_(session: Session) -> None: def format_(session):
"""Format source files with autoflake, black, and isort. """Format source files with autoflake, black, and isort.
If no extra arguments are provided, all source files are formatted. If no extra arguments are provided, all source files are formatted.
@ -68,7 +70,7 @@ def format_(session: Session) -> None:
@nox.session(python=MAIN_PYTHON) @nox.session(python=MAIN_PYTHON)
def lint(session: Session) -> None: def lint(session):
"""Lint source files with flake8, mypy, and pylint. """Lint source files with flake8, mypy, and pylint.
If no extra arguments are provided, all source files are linted. If no extra arguments are provided, all source files are linted.
@ -93,8 +95,15 @@ def lint(session: Session) -> None:
with _isort_fix(session): # TODO (isort): Remove after upgrading with _isort_fix(session): # TODO (isort): Remove after upgrading
session.run('isort', '--version') session.run('isort', '--version')
session.run('isort', '--check-only', *locations) session.run('isort', '--check-only', *locations)
# For mypy, only lint *.py files to be packaged.
mypy_locations = [
path for path in locations if path.startswith(PACKAGE_SOURCE_LOCATION)
]
if mypy_locations:
session.run('mypy', '--version') session.run('mypy', '--version')
session.run('mypy', *locations) session.run('mypy', *mypy_locations)
else:
session.log('No paths to be checked with mypy')
# Ignore errors where pylint cannot import a third-party package due its # Ignore errors where pylint cannot import a third-party package due its
# being run in an isolated environment. For the same reason, pylint is # being run in an isolated environment. For the same reason, pylint is
# also not able to determine the correct order of imports. # also not able to determine the correct order of imports.
@ -109,7 +118,7 @@ def lint(session: Session) -> None:
@nox.session(python=[MAIN_PYTHON, NEXT_PYTHON]) @nox.session(python=[MAIN_PYTHON, NEXT_PYTHON])
def test(session: Session) -> None: def test(session):
"""Test the code base. """Test the code base.
Runs the unit and integration tests (written with pytest). Runs the unit and integration tests (written with pytest).
@ -148,7 +157,7 @@ def test(session: Session) -> None:
@nox.session(name='pre-commit', python=MAIN_PYTHON, venv_backend='none') @nox.session(name='pre-commit', python=MAIN_PYTHON, venv_backend='none')
def pre_commit(session: Session) -> None: def pre_commit(session):
"""Source files must be well-formed before they enter git. """Source files must be well-formed before they enter git.
Intended to be run as a pre-commit hook. Intended to be run as a pre-commit hook.
@ -165,7 +174,7 @@ def pre_commit(session: Session) -> None:
@nox.session(name='pre-merge', python=MAIN_PYTHON) @nox.session(name='pre-merge', python=MAIN_PYTHON)
def pre_merge(session: Session) -> None: def pre_merge(session):
"""The test suite must pass before merges are made. """The test suite must pass before merges are made.
Intended to be run either as a pre-merge or pre-push hook. Intended to be run either as a pre-merge or pre-push hook.
@ -188,7 +197,7 @@ def pre_merge(session: Session) -> None:
test(session) test(session)
def _begin(session: Session) -> None: def _begin(session):
"""Show generic info about a session.""" """Show generic info about a session."""
if session.posargs: if session.posargs:
# Part of the hack in pre_merge() to "drop" the extra arguments. # Part of the hack in pre_merge() to "drop" the extra arguments.
@ -205,9 +214,7 @@ def _begin(session: Session) -> None:
print(os.getcwd()) # noqa:WPS421 print(os.getcwd()) # noqa:WPS421
def _install_packages( def _install_packages(session: Session, *packages_or_pip_args: str, **kwargs) -> None:
session: Session, *packages_or_pip_args: str, **kwargs: Any,
) -> None:
"""Install packages respecting the poetry.lock file. """Install packages respecting the poetry.lock file.
This function wraps nox.sessions.Session.install() such that it installs This function wraps nox.sessions.Session.install() such that it installs
@ -251,7 +258,7 @@ def _install_packages(
# TODO (isort): Remove this fix after # TODO (isort): Remove this fix after
# upgrading to isort ^5.2.2 in pyproject.toml. # upgrading to isort ^5.2.2 in pyproject.toml.
@contextlib.contextmanager @contextlib.contextmanager
def _isort_fix(session: Session) -> Generator: def _isort_fix(session):
"""Temporarily upgrade to isort 5.2.2.""" """Temporarily upgrade to isort 5.2.2."""
session.install('isort==5.2.2') session.install('isort==5.2.2')
try: try:

View file

@ -96,6 +96,8 @@ extend-ignore =
per-file-ignores = per-file-ignores =
noxfile.py: noxfile.py:
# Type annotations are not strictly enforced.
ANN0, ANN2,
# TODO (isort): Check if still too many module members. # TODO (isort): Check if still too many module members.
WPS202, WPS202,
# TODO (isort): Remove after simplifying the nox session "lint". # TODO (isort): Remove after simplifying the nox session "lint".
@ -103,6 +105,8 @@ per-file-ignores =
# No overuse of string constants (e.g., '--version'). # No overuse of string constants (e.g., '--version').
WPS226, WPS226,
tests/*.py: tests/*.py:
# Type annotations are not strictly enforced.
ANN0, ANN2,
# `assert` statements are ok in the test suite. # `assert` statements are ok in the test suite.
S101, S101,
# Shadowing outer scopes occurs naturally with mocks. # Shadowing outer scopes occurs naturally with mocks.
@ -177,7 +181,7 @@ single_line_exclusions = typing
[mypy] [mypy]
cache_dir = .cache/mypy cache_dir = .cache/mypy
[mypy-nox.*,packaging.*,pytest,_pytest.*] [mypy-nox.*,packaging,pytest]
ignore_missing_imports = true ignore_missing_imports = true

View file

@ -2,19 +2,11 @@
import click import click
import pytest import pytest
from _pytest.capture import CaptureFixture # noqa:WPS436
from _pytest.monkeypatch import MonkeyPatch # noqa:WPS436
from click import testing as click_testing from click import testing as click_testing
from urban_meal_delivery import console from urban_meal_delivery import console
@pytest.fixture
def ctx() -> click.Context:
"""Context around the console.main Command."""
return click.Context(console.main)
class TestShowVersion: class TestShowVersion:
"""Test console.show_version(). """Test console.show_version().
@ -29,7 +21,12 @@ class TestShowVersion:
# pylint:disable=no-self-use # pylint:disable=no-self-use
def test_no_version(self, capsys: CaptureFixture, ctx: click.Context) -> None: @pytest.fixture
def ctx(self) -> click.Context:
"""Context around the console.main Command."""
return click.Context(console.main)
def test_no_version(self, capsys, ctx):
"""The the early exit branch without any output.""" """The the early exit branch without any output."""
console.show_version(ctx, _param='discarded', value=False) console.show_version(ctx, _param='discarded', value=False)
@ -37,9 +34,7 @@ class TestShowVersion:
assert captured.out == '' assert captured.out == ''
def test_final_version( def test_final_version(self, capsys, ctx, monkeypatch):
self, capsys: CaptureFixture, ctx: click.Context, monkeypatch: MonkeyPatch,
) -> None:
"""For final versions, NO "development" warning is emitted.""" """For final versions, NO "development" warning is emitted."""
version = '1.2.3' version = '1.2.3'
monkeypatch.setattr(console.urban_meal_delivery, '__version__', version) monkeypatch.setattr(console.urban_meal_delivery, '__version__', version)
@ -51,9 +46,7 @@ class TestShowVersion:
assert captured.out.endswith(f', version {version}\n') assert captured.out.endswith(f', version {version}\n')
def test_develop_version( def test_develop_version(self, capsys, ctx, monkeypatch):
self, capsys: CaptureFixture, ctx: click.Context, monkeypatch: MonkeyPatch,
) -> None:
"""For develop versions, a warning thereof is emitted.""" """For develop versions, a warning thereof is emitted."""
version = '1.2.3.dev0' version = '1.2.3.dev0'
monkeypatch.setattr(console.urban_meal_delivery, '__version__', version) monkeypatch.setattr(console.urban_meal_delivery, '__version__', version)
@ -66,12 +59,6 @@ class TestShowVersion:
assert captured.out.strip().endswith(f', version {version} (development)') assert captured.out.strip().endswith(f', version {version} (development)')
@pytest.fixture
def cli() -> click_testing.CliRunner:
"""Initialize Click's CLI Test Runner."""
return click_testing.CliRunner()
class TestCLI: class TestCLI:
"""Test the `umd` CLI utility. """Test the `umd` CLI utility.
@ -81,8 +68,13 @@ class TestCLI:
# pylint:disable=no-self-use # pylint:disable=no-self-use
@pytest.fixture
def cli(self) -> click_testing.CliRunner:
"""Initialize Click's CLI Test Runner."""
return click_testing.CliRunner()
@pytest.mark.no_cover @pytest.mark.no_cover
def test_no_options(self, cli: click_testing.CliRunner) -> None: def test_no_options(self, cli):
"""Exit with 0 status code and no output if run without options.""" """Exit with 0 status code and no output if run without options."""
result = cli.invoke(console.main) result = cli.invoke(console.main)
@ -94,12 +86,8 @@ class TestCLI:
version_options = ('--version', '-V') version_options = ('--version', '-V')
@pytest.mark.no_cover @pytest.mark.no_cover
@pytest.mark.parametrize( @pytest.mark.parametrize('option', version_options)
'option', version_options, def test_final_version(self, cli, monkeypatch, option):
)
def test_final_version(
self, cli: click_testing.CliRunner, monkeypatch: MonkeyPatch, option: str,
) -> None:
"""For final versions, NO "development" warning is emitted.""" """For final versions, NO "development" warning is emitted."""
version = '1.2.3' version = '1.2.3'
monkeypatch.setattr(console.urban_meal_delivery, '__version__', version) monkeypatch.setattr(console.urban_meal_delivery, '__version__', version)
@ -110,12 +98,8 @@ class TestCLI:
assert result.output.strip().endswith(f', version {version}') assert result.output.strip().endswith(f', version {version}')
@pytest.mark.no_cover @pytest.mark.no_cover
@pytest.mark.parametrize( @pytest.mark.parametrize('option', version_options)
'option', version_options, def test_develop_version(self, cli, monkeypatch, option):
)
def test_develop_version(
self, cli: click_testing.CliRunner, monkeypatch: MonkeyPatch, option: str,
) -> None:
"""For develop versions, a warning thereof is emitted.""" """For develop versions, a warning thereof is emitted."""
version = '1.2.3.dev0' version = '1.2.3.dev0'
monkeypatch.setattr(console.urban_meal_delivery, '__version__', version) monkeypatch.setattr(console.urban_meal_delivery, '__version__', version)

View file

@ -13,35 +13,33 @@ import re
import pytest import pytest
from packaging import version as pkg_version from packaging import version as pkg_version
from packaging.version import Version
import urban_meal_delivery import urban_meal_delivery
@pytest.fixture
def parsed_version() -> str:
"""The packaged version."""
return pkg_version.Version(urban_meal_delivery.__version__) # noqa:WPS609
class TestPEP404Compliance: class TestPEP404Compliance:
"""Packaged version identifier is PEP440 compliant.""" """Packaged version identifier is PEP440 compliant."""
# pylint:disable=no-self-use # pylint:disable=no-self-use
def test_parsed_version_has_no_epoch(self, parsed_version: Version) -> None: @pytest.fixture
def parsed_version(self) -> str:
"""The packaged version."""
return pkg_version.Version(urban_meal_delivery.__version__) # noqa:WPS609
def test_parsed_version_has_no_epoch(self, parsed_version):
"""PEP440 compliant subset of semantic versioning: no epoch.""" """PEP440 compliant subset of semantic versioning: no epoch."""
assert parsed_version.epoch == 0 assert parsed_version.epoch == 0
def test_parsed_version_is_non_local(self, parsed_version: Version) -> None: def test_parsed_version_is_non_local(self, parsed_version):
"""PEP440 compliant subset of semantic versioning: no local version.""" """PEP440 compliant subset of semantic versioning: no local version."""
assert parsed_version.local is None assert parsed_version.local is None
def test_parsed_version_is_no_post_release(self, parsed_version: Version) -> None: def test_parsed_version_is_no_post_release(self, parsed_version):
"""PEP440 compliant subset of semantic versioning: no post releases.""" """PEP440 compliant subset of semantic versioning: no post releases."""
assert parsed_version.is_postrelease is False assert parsed_version.is_postrelease is False
def test_parsed_version_is_all_public(self, parsed_version: Version) -> None: def test_parsed_version_is_all_public(self, parsed_version):
"""PEP440 compliant subset of semantic versioning: all public parts.""" """PEP440 compliant subset of semantic versioning: all public parts."""
assert parsed_version.public == urban_meal_delivery.__version__ # noqa:WPS609 assert parsed_version.public == urban_meal_delivery.__version__ # noqa:WPS609
@ -55,7 +53,7 @@ class TestSemanticVersioning:
r'^(0|([1-9]\d*))\.(0|([1-9]\d*))\.(0|([1-9]\d*))(\.dev(0|([1-9]\d*)))?$', r'^(0|([1-9]\d*))\.(0|([1-9]\d*))\.(0|([1-9]\d*))(\.dev(0|([1-9]\d*)))?$',
) )
def test_version_is_semantic(self) -> None: def test_version_is_semantic(self):
"""Packaged version follows semantic versioning.""" """Packaged version follows semantic versioning."""
result = self.version_pattern.fullmatch( result = self.version_pattern.fullmatch(
urban_meal_delivery.__version__, # noqa:WPS609 urban_meal_delivery.__version__, # noqa:WPS609
@ -79,7 +77,7 @@ class TestSemanticVersioning:
'1.2.3.dev10', '1.2.3.dev10',
], ],
) )
def test_valid_semantic_versioning(self, version: str) -> None: def test_valid_semantic_versioning(self, version):
"""Versions follow the x.y.z or x.y.z.devN format.""" """Versions follow the x.y.z or x.y.z.devN format."""
result = self.version_pattern.fullmatch(version) result = self.version_pattern.fullmatch(version)
@ -109,7 +107,7 @@ class TestSemanticVersioning:
'1.2..3', '1.2..3',
], ],
) )
def test_invalid_semantic_versioning(self, version: str) -> None: def test_invalid_semantic_versioning(self, version):
"""Versions follow the x.y.z or x.y.z.devN format.""" """Versions follow the x.y.z or x.y.z.devN format."""
result = self.version_pattern.fullmatch(version) result = self.version_pattern.fullmatch(version)