diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 44f2e25..02e65af 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -10,7 +10,7 @@ on:
branches: [ master ]
# Allow rebuilds via API.
repository_dispatch:
- types: rebuild
+ types: [rebuild]
env:
FORCE_COLOR: "1"
@@ -51,6 +51,14 @@ jobs:
- name: Run tox targets for ${{ matrix.python-version }}
run: python -Im tox run -f py$(echo ${{ matrix.python-version }} | tr -d .)
+ - name: Run mypy
+ run: python -Im tox run -e mypy
+ if: matrix.python-version == '3.11'
+
+ - name: Run mypy
+ run: python -Im tox run -e mypy
+ if: matrix.python-version == '3.11'
+
- name: Check MANIFEST.in
run: python -Im tox run -e manifest
if: matrix.python-version == '3.11'
@@ -127,6 +135,9 @@ jobs:
- name: "Create a badge"
run: interrogate --config pyproject.toml --generate-badge . src tests
if: runner.os != 'Windows'
+ env:
+ # TODO: set for only macos
+ DYLD_FALLBACK_LIBRARY_PATH: "/opt/homebrew/lib"
docs:
name: Check docs
diff --git a/README.rst b/README.rst
index 672699c..8a1e304 100644
--- a/README.rst
+++ b/README.rst
@@ -74,11 +74,31 @@ To generate a **PNG file** instead, install ``interrogate`` with the extras ``[p
**NOTICE:** Additional system libraries/tools may be required in order to generate a PNG file of the coverage badge:
* on Windows, install Visual C++ compiler for Cairo;
-* on macOS, install ``cairo`` and ``libffi`` (with Homebrew for example);
+* on macOS, install ``cairo`` and ``libffi`` (with Homebrew for example - `see note below <#macos-and-cairo>`_);
* on Linux, install the ``cairo``, ``python3-dev`` and ``libffi-dev`` packages (names may vary depending on distribution).
Refer to the ``cairosvg`` `documentation `_ for more information.
+MacOS and Cairo
+^^^^^^^^^^^^^^^
+
+If you get an error when trying to generate a badge like so:
+
+.. code-block:: console
+
+ OSError: no library called "cairo-2" was found
+ no library called "cairo" was found
+ no library called "libcairo-2" was found
+
+
+Then first try:
+
+.. code-block:: console
+
+ export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib
+
+And rerun the command.
+
Usage
=====
@@ -373,7 +393,7 @@ Configure within your ``pyproject.toml`` (``interrogate`` will automatically det
ignore-regex = ["^get$", "^mock_.*", ".*BaseClass.*"]
ext = []
# possible values: sphinx (default), google
- style = sphinx
+ style = "sphinx"
# possible values: 0 (minimal output), 1 (-v), 2 (-vv)
verbose = 0
quiet = false
diff --git a/docs/Makefile b/docs/Makefile
index 21a0781..c895b1d 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -10,6 +10,9 @@ ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif
+# Internal variables
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(SPHINXOPTS) .
+
.PHONY: help clean html livehtml linkcheck
help:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 24f7456..07dfb94 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,6 +1,14 @@
Changelog
=========
+1.8.0 (UNRELEASED)
+------------------
+
+Added
+^^^^^
+
+* Finally added type hints
+
.. short-log
1.7.0 (2024-04-07)
diff --git a/pyproject.toml b/pyproject.toml
index d0a57dd..eba8295 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,11 +29,8 @@ fail_under = 95
line-length = 79
[tool.isort]
-atomic=true
-force_single_line=true
lines_after_imports=2
lines_between_types=1
-use_parentheses=true
known_first_party="interrogate"
known_third_party=["attr", "click", "py", "pytest", "setuptools", "tabulate"]
@@ -53,3 +50,8 @@ quiet = false
whitelist-regex = []
ignore-regex = []
color = true
+
+[tool.mypy]
+strict = true
+pretty = true
+ignore_missing_imports = true
diff --git a/setup.py b/setup.py
index 8a42f4f..aeeb025 100644
--- a/setup.py
+++ b/setup.py
@@ -5,8 +5,7 @@
import os
import re
-from setuptools import find_packages
-from setuptools import setup
+from setuptools import find_packages, setup
HERE = os.path.abspath(os.path.dirname(__file__))
@@ -80,11 +79,13 @@ def find_meta(meta):
"png": ["cairosvg"],
"docs": ["sphinx", "sphinx-autobuild"],
"tests": ["pytest", "pytest-cov", "pytest-mock", "coverage[toml]"],
+ "typing": ["mypy", "types-tabulate"],
}
EXTRAS_REQUIRE["dev"] = (
EXTRAS_REQUIRE["png"]
+ EXTRAS_REQUIRE["docs"]
+ EXTRAS_REQUIRE["tests"]
+ + EXTRAS_REQUIRE["typing"]
+ ["wheel", "pre-commit"]
)
URL = find_meta("uri")
diff --git a/src/interrogate/__init__.py b/src/interrogate/__init__.py
index 5b846c2..1d4ee72 100644
--- a/src/interrogate/__init__.py
+++ b/src/interrogate/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2020-2021 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Explain yourself! Interrogate a codebase for docstring coverage."""
__author__ = "Lynn Root"
__version__ = "1.7.0"
diff --git a/src/interrogate/__main__.py b/src/interrogate/__main__.py
index 2c58f85..52b1d12 100644
--- a/src/interrogate/__main__.py
+++ b/src/interrogate/__main__.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""interrogate entrypoint"""
from interrogate import cli
diff --git a/src/interrogate/badge_gen.py b/src/interrogate/badge_gen.py
index 763613d..8a9479c 100644
--- a/src/interrogate/badge_gen.py
+++ b/src/interrogate/badge_gen.py
@@ -1,13 +1,15 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Module for generating an SVG badge.
Inspired by `coverage-badge `_.
"""
+from __future__ import annotations
import os
import sys
from importlib import resources
+from typing import Union
from xml.dom import minidom
@@ -16,9 +18,13 @@
except ImportError: # pragma: no cover
cairosvg = None
+from interrogate.coverage import InterrogateResults
-DEFAULT_FILENAME = "interrogate_badge"
-COLORS = {
+
+NumberType = Union[int, float]
+
+DEFAULT_FILENAME: str = "interrogate_badge"
+COLORS: dict[str, str] = {
"brightgreen": "#4c1",
"green": "#97CA00",
"yellowgreen": "#a4a61d",
@@ -28,7 +34,7 @@
"lightgrey": "#9f9f9f",
}
-COLOR_RANGES = [
+COLOR_RANGES: list[tuple[int, str]] = [
(95, "brightgreen"),
(90, "green"),
(75, "yellowgreen"),
@@ -36,11 +42,13 @@
(40, "orange"),
(0, "red"),
]
-SUPPORTED_OUTPUT_FORMATS = ["svg", "png"]
+SUPPORTED_OUTPUT_FORMATS: list[str] = ["svg", "png"]
# depending on the character length of the result (e.g. 100, 99.9, 9.9)
# a few values in the svg template need to adjust so it's readable.
# Tuple of values: (svg_width, rect_width, text_x, text_length)
-SVG_WIDTH_VALUES = {
+SVG_WIDTH_VALUES: dict[
+ str, dict[str, tuple[int, int, NumberType, NumberType]]
+] = {
# integer
"100": {
"plastic": (135, 43, 1140, 330),
@@ -71,7 +79,9 @@
}
-def save_badge(badge, output, output_format=None):
+def save_badge(
+ badge: str, output: str, output_format: str | None = None
+) -> str:
"""Save badge to the specified path.
.. versionadded:: 1.4.0 new ``output_format`` keyword argument
@@ -116,7 +126,9 @@ def save_badge(badge, output, output_format=None):
return output
-def _get_badge_measurements(result, style):
+def _get_badge_measurements(
+ result: float, style: str
+) -> dict[str, NumberType]:
"""Lookup templated style values based on result number."""
if result == 100:
width_values = SVG_WIDTH_VALUES["100"]
@@ -133,7 +145,7 @@ def _get_badge_measurements(result, style):
}
-def _format_result(result):
+def _format_result(result: float) -> str:
"""Format result into string for templating."""
# do not include decimal if it's 100
if result == 100:
@@ -141,7 +153,7 @@ def _format_result(result):
return f"{result:.1f}"
-def get_badge(result, color, style=None):
+def get_badge(result: float, color: str, style: str | None = None) -> str:
"""Generate an SVG from template.
:param float result: coverage % result.
@@ -154,9 +166,9 @@ def get_badge(result, color, style=None):
style = "flat-square-modified"
template_file = f"{style}-style.svg"
badge_template_values = _get_badge_measurements(result, style)
- result = _format_result(result)
- badge_template_values["result"] = result
- badge_template_values["color"] = color
+ formatted_result = _format_result(result)
+ badge_template_values["result"] = formatted_result # type: ignore
+ badge_template_values["color"] = color # type: ignore
if sys.version_info >= (3, 9):
tmpl = (
@@ -171,7 +183,7 @@ def get_badge(result, color, style=None):
return tmpl
-def should_generate_badge(output, color, result):
+def should_generate_badge(output: str, color: str, result: float) -> bool:
"""Detect if existing badge needs updating.
This is to help avoid unnecessary newline updates. See
@@ -186,8 +198,8 @@ def should_generate_badge(output, color, result):
logo doesn't exist.
:param str output: path to output badge file
- :param float result: coverage % result.
:param str color: color of badge.
+ :param float result: coverage % result.
:return: Whether or not the badge SVG file should be generated.
:rtype: bool
"""
@@ -228,13 +240,13 @@ def should_generate_badge(output, color, result):
for t in texts
if t.hasAttribute("data-interrogate")
]
- result = f"{result:.1f}%"
- if result in current_results:
+ formatted_result = f"{result:.1f}%"
+ if formatted_result in current_results:
return False
return True
-def get_color(result):
+def get_color(result: float) -> str:
"""Get color for current doc coverage percent.
:param float result: coverage % result
@@ -247,7 +259,12 @@ def get_color(result):
return COLORS["lightgrey"]
-def create(output, result, output_format=None, output_style=None):
+def create(
+ output: str,
+ result: InterrogateResults,
+ output_format: str | None = None,
+ output_style: str | None = None,
+) -> str:
"""Create a status badge.
The badge file will only be written if it doesn't exist, or if the
@@ -263,6 +280,11 @@ def create(output, result, output_format=None, output_style=None):
:param str output: path to output badge file.
:param coverage.InterrogateResults result: results of coverage
interrogation.
+ :param str output_format: output format of the badge. Options: "svg", "png".
+ Default: "svg"
+ :param str output_style: badge styling. Options: "plastic", "social",
+ "flat", "flat-square", "flat-square-modified", "for-the-badge".
+ Default: "flat-square-modified"
:return: path to output badge file.
:rtype: str
"""
diff --git a/src/interrogate/cli.py b/src/interrogate/cli.py
index ec71370..86b9084 100644
--- a/src/interrogate/cli.py
+++ b/src/interrogate/cli.py
@@ -1,17 +1,18 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""CLI entrypoint into `interrogate`."""
import os
import sys
+from typing import List, Optional, Pattern, Tuple, Union
+
import click
import colorama
from interrogate import __version__ as version
from interrogate import badge_gen
-from interrogate import config
-from interrogate import coverage
-from interrogate import utils
+from interrogate import config as int_config
+from interrogate import coverage, utils
@click.command()
@@ -101,20 +102,20 @@
help="Ignore module-level docstrings.",
)
@click.option(
- "-n",
- "--ignore-nested-functions",
+ "-C",
+ "--ignore-nested-classes",
is_flag=True,
default=False,
show_default=True,
- help="Ignore nested functions and methods.",
+ help="Ignore nested classes.",
)
@click.option(
- "-C",
- "--ignore-nested-classes",
+ "-n",
+ "--ignore-nested-functions",
is_flag=True,
default=False,
show_default=True,
- help="Ignore nested classes.",
+ help="Ignore nested functions and methods.",
)
@click.option(
"-O",
@@ -182,7 +183,7 @@
default=(),
multiple=True,
metavar="STR",
- type=click.Choice(["pyi"], case_sensitive=False),
+ type=click.Choice(["pyi", "ipynb"], case_sensitive=False),
help=(
"Include Python-like files with the given extension "
"(supported: ``pyi``). Multiple ``--ext`` invocations supported. "
@@ -308,11 +309,38 @@
exists=False, file_okay=True, dir_okay=False, readable=True
),
is_eager=True,
- callback=config.read_config_file,
+ callback=int_config.read_config_file,
help="Read configuration from `pyproject.toml` or `setup.cfg`.",
)
-@click.pass_context
-def main(ctx, paths, **kwargs):
+def main(
+ paths: Optional[List[str]],
+ verbose: int,
+ quiet: bool,
+ fail_under: Union[int, float],
+ exclude: Tuple[str],
+ ignore_init_method: bool,
+ ignore_init_module: bool,
+ ignore_magic: bool,
+ ignore_module: bool,
+ ignore_nested_classes: bool,
+ ignore_nested_functions: bool,
+ ignore_overloaded_functions: bool,
+ ignore_private: bool,
+ ignore_property_decorators: bool,
+ ignore_setters: bool,
+ ignore_semiprivate: bool,
+ ignore_regex: Optional[List[Pattern[str]]],
+ ext: Tuple[str],
+ whitelist_regex: Optional[List[Pattern[str]]],
+ style: str,
+ output: Optional[str],
+ color: bool,
+ omit_covered_files: bool,
+ generate_badge: Optional[str],
+ badge_format: Optional[str],
+ badge_style: Optional[str],
+ config: Optional[str],
+) -> None:
"""Measure and report on documentation coverage in Python modules.
\f
@@ -342,18 +370,17 @@ def main(ctx, paths, **kwargs):
.. versionchanged:: 1.7.0 include property deleters when ignoring all
property decorators (--ignore-property-decorators)
"""
- gen_badge = kwargs["generate_badge"]
- if kwargs["badge_format"] is not None and gen_badge is None:
+ if badge_format is not None and generate_badge is None:
raise click.BadParameter(
"The `--badge-format` option must be used along with the `-g/"
"--generate-badge option."
)
- if kwargs["badge_style"] is not None and gen_badge is None:
+ if badge_style is not None and generate_badge is None:
raise click.BadParameter(
"The `--badge-style` option must be used along with the `-g/"
"--generate-badge option."
)
- if kwargs["style"] == "google" and kwargs["ignore_init_method"]:
+ if style == "google" and ignore_init_method:
raise click.BadOptionUsage(
option_name="style",
message=(
@@ -362,60 +389,57 @@ def main(ctx, paths, **kwargs):
),
)
if not paths:
- paths = (os.path.abspath(os.getcwd()),)
+ paths = [os.path.abspath(os.getcwd())]
# NOTE: this will need to be fixed if we want to start supporting
# --whitelist-regex on filenames. This otherwise assumes you
# want to ignore module-level docs when only white listing
# items (visit.py will also need to be addressed since white/
# black listing only looks at classes & funcs, not modules).
- if kwargs["whitelist_regex"]:
- kwargs["ignore_module"] = True
+ if whitelist_regex:
+ ignore_module = True
- conf = config.InterrogateConfig(
- color=kwargs["color"],
- docstring_style=kwargs["style"],
- fail_under=kwargs["fail_under"],
- ignore_regex=kwargs["ignore_regex"],
- ignore_magic=kwargs["ignore_magic"],
- ignore_module=kwargs["ignore_module"],
- ignore_private=kwargs["ignore_private"],
- ignore_semiprivate=kwargs["ignore_semiprivate"],
- ignore_init_method=kwargs["ignore_init_method"],
- ignore_init_module=kwargs["ignore_init_module"],
- ignore_nested_classes=kwargs["ignore_nested_classes"],
- ignore_nested_functions=kwargs["ignore_nested_functions"],
- ignore_overloaded_functions=kwargs["ignore_overloaded_functions"],
- ignore_property_setters=kwargs["ignore_setters"],
- ignore_property_decorators=kwargs["ignore_property_decorators"],
- include_regex=kwargs["whitelist_regex"],
- omit_covered_files=kwargs["omit_covered_files"],
+ conf = int_config.InterrogateConfig(
+ color=color,
+ docstring_style=style,
+ fail_under=fail_under,
+ ignore_regex=ignore_regex,
+ ignore_magic=ignore_magic,
+ ignore_module=ignore_module,
+ ignore_private=ignore_private,
+ ignore_semiprivate=ignore_semiprivate,
+ ignore_init_method=ignore_init_method,
+ ignore_init_module=ignore_init_module,
+ ignore_nested_classes=ignore_nested_classes,
+ ignore_nested_functions=ignore_nested_functions,
+ ignore_overloaded_functions=ignore_overloaded_functions,
+ ignore_property_setters=ignore_setters,
+ ignore_property_decorators=ignore_property_decorators,
+ include_regex=whitelist_regex,
+ omit_covered_files=omit_covered_files,
)
interrogate_coverage = coverage.InterrogateCoverage(
paths=paths,
conf=conf,
- excluded=kwargs["exclude"],
- extensions=kwargs["ext"],
+ excluded=exclude,
+ extensions=ext,
)
results = interrogate_coverage.get_coverage()
- is_quiet = kwargs["quiet"]
- if not is_quiet:
+ if not quiet:
colorama.init() # needed for Windows
- interrogate_coverage.print_results(
- results, kwargs["output"], kwargs["verbose"]
- )
+ interrogate_coverage.print_results(results, output, verbose)
- if gen_badge is not None:
- badge_format = kwargs["badge_format"]
- badge_style = kwargs["badge_style"]
+ if generate_badge is not None:
+ badge_format = badge_format
+ badge_style = badge_style
output_path = badge_gen.create(
- gen_badge,
+ generate_badge,
results,
output_format=badge_format,
output_style=badge_style,
)
- if not is_quiet:
+ if not quiet:
click.echo(f"Generated badge to {output_path}")
sys.exit(results.ret_code)
diff --git a/src/interrogate/config.py b/src/interrogate/config.py
index d3fdafa..13dc0ac 100644
--- a/src/interrogate/config.py
+++ b/src/interrogate/config.py
@@ -1,12 +1,18 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""
Configuration-related helpers.
"""
# Adapted from Black https://github.com/psf/black/blob/master/black.py.
+from __future__ import annotations
+
import configparser
import os
import pathlib
+import re
+
+from collections.abc import Sequence
+from typing import Any
import attr
import click
@@ -15,7 +21,7 @@
try:
import tomllib
except ImportError:
- import tomli as tomllib
+ import tomli as tomllib # type: ignore
# TODO: idea: break out InterrogateConfig into two classes: one for
@@ -52,26 +58,26 @@ class InterrogateConfig:
VALID_STYLES = ("sphinx", "google")
- color = attr.ib(default=False)
- docstring_style = attr.ib(default="sphinx")
- fail_under = attr.ib(default=80.0)
- ignore_regex = attr.ib(default=False)
- ignore_magic = attr.ib(default=False)
- ignore_module = attr.ib(default=False)
- ignore_private = attr.ib(default=False)
- ignore_semiprivate = attr.ib(default=False)
- ignore_init_method = attr.ib(default=False)
- ignore_init_module = attr.ib(default=False)
- ignore_nested_classes = attr.ib(default=False)
- ignore_nested_functions = attr.ib(default=False)
- ignore_property_setters = attr.ib(default=False)
- ignore_property_decorators = attr.ib(default=False)
- ignore_overloaded_functions = attr.ib(default=False)
- include_regex = attr.ib(default=False)
- omit_covered_files = attr.ib(default=False)
+ color: bool = attr.ib(default=False)
+ docstring_style: str = attr.ib(default="sphinx")
+ fail_under: float = attr.ib(default=80.0)
+ ignore_regex: list[re.Pattern[str]] | None = attr.ib(default=None)
+ ignore_magic: bool = attr.ib(default=False)
+ ignore_module: bool = attr.ib(default=False)
+ ignore_private: bool = attr.ib(default=False)
+ ignore_semiprivate: bool = attr.ib(default=False)
+ ignore_init_method: bool = attr.ib(default=False)
+ ignore_init_module: bool = attr.ib(default=False)
+ ignore_nested_classes: bool = attr.ib(default=False)
+ ignore_nested_functions: bool = attr.ib(default=False)
+ ignore_property_setters: bool = attr.ib(default=False)
+ ignore_property_decorators: bool = attr.ib(default=False)
+ ignore_overloaded_functions: bool = attr.ib(default=False)
+ include_regex: list[re.Pattern[str]] | None = attr.ib(default=None)
+ omit_covered_files: bool = attr.ib(default=False)
@docstring_style.validator
- def _check_style(self, attribute, value):
+ def _check_style(self, attribute: str, value: str) -> None:
"""Validate selected choice for docstring style"""
if value not in self.VALID_STYLES:
raise ValueError(
@@ -80,7 +86,7 @@ def _check_style(self, attribute, value):
)
-def find_project_root(srcs):
+def find_project_root(srcs: Sequence[str]) -> pathlib.Path:
"""Return a directory containing .git, .hg, or pyproject.toml.
That directory can be one of the directories passed in `srcs` or their
common parent.
@@ -108,7 +114,7 @@ def find_project_root(srcs):
return directory
-def find_project_config(path_search_start):
+def find_project_config(path_search_start: Sequence[str]) -> str | None:
"""Find the absolute filepath to a pyproject.toml if it exists."""
project_root = find_project_root(path_search_start)
pyproject_toml = project_root / "pyproject.toml"
@@ -119,7 +125,7 @@ def find_project_config(path_search_start):
return str(setup_cfg) if setup_cfg.is_file() else None
-def parse_pyproject_toml(path_config):
+def parse_pyproject_toml(path_config: str) -> dict[str, Any]:
"""Parse ``pyproject.toml`` file and return relevant parts for Interrogate.
:param str path_config: Path to ``pyproject.toml`` file.
@@ -136,7 +142,7 @@ def parse_pyproject_toml(path_config):
}
-def sanitize_list_values(value):
+def sanitize_list_values(value: str) -> list[str | None]:
"""Parse a string of list items to a Python list.
This is super hacky...
@@ -159,7 +165,7 @@ def sanitize_list_values(value):
return [v.strip('"') for v in raw_values]
-def parse_setup_cfg(path_config):
+def parse_setup_cfg(path_config: str) -> dict[str, Any] | None:
"""Parse ``setup.cfg`` file and return relevant parts for Interrogate.
This is super hacky...
@@ -185,15 +191,17 @@ def parse_setup_cfg(path_config):
}
for k, v in config.items():
if k in keys_for_list_values:
- config[k] = sanitize_list_values(v)
+ config[k] = sanitize_list_values(v) # type: ignore
elif v.lower() == "false":
- config[k] = False
+ config[k] = False # type: ignore
elif v.lower() == "true":
- config[k] = True
+ config[k] = True # type: ignore
return config
-def read_config_file(ctx, param, value):
+def read_config_file(
+ ctx: click.Context, param: click.Parameter, value: str | None
+) -> str | None:
"""Inject config from ``pyproject.toml`` or ``setup.py`` into ``ctx``.
These override option defaults, but still respect option values
diff --git a/src/interrogate/coverage.py b/src/interrogate/coverage.py
index ebf4bf7..4c1226b 100644
--- a/src/interrogate/coverage.py
+++ b/src/interrogate/coverage.py
@@ -1,20 +1,21 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Measure and report on documentation coverage in Python modules."""
+
+from __future__ import annotations
+
import ast
import decimal
+import fnmatch
import os
-import pathlib
import sys
-from fnmatch import fnmatch
+from typing import Final, Iterator
import attr
import click
import tabulate
-from interrogate import config
-from interrogate import utils
-from interrogate import visit
+from interrogate import config, utils, visit
tabulate.PRESERVE_WHITESPACE = True
@@ -30,12 +31,12 @@ class BaseInterrogateResult:
:attr int missing: number of objects not covered by docstrings.
"""
- total = attr.ib(init=False, default=0)
- covered = attr.ib(init=False, default=0)
- missing = attr.ib(init=False, default=0)
+ total: int = attr.ib(init=False, default=0)
+ covered: int = attr.ib(init=False, default=0)
+ missing: int = attr.ib(init=False, default=0)
@property
- def perc_covered(self):
+ def perc_covered(self) -> float:
"""Percentage of node covered.
.. versionchanged:: 1.3.2
@@ -57,15 +58,14 @@ class InterrogateFileResult(BaseInterrogateResult):
:param str filename: filename associated with the coverage result.
:param bool ignore_module: whether or not to ignore this file/module.
- :param visit.CoverageVisitor visitor: coverage visitor instance
- that assessed docstring coverage of file.
+ :param list[visit.CovNode] nodes: visited AST nodes.
"""
- filename = attr.ib(default=None)
- ignore_module = attr.ib(default=False)
- nodes = attr.ib(repr=False, default=None)
+ filename: str = attr.ib(default=None)
+ ignore_module: bool = attr.ib(default=False)
+ nodes: list[visit.CovNode] = attr.ib(repr=False, default=None)
- def combine(self):
+ def combine(self) -> None:
"""Tally results from each AST node visited."""
for node in self.nodes:
if node.node_type == "Module":
@@ -84,14 +84,16 @@ class InterrogateResults(BaseInterrogateResult):
:attr int ret_code: return code of program (``0`` for success, ``1``
for fail).
- :attr list(InterrogateFileResults) file_results: list of file
+ :attr list(InterrogateFileResult) file_results: list of file
results associated with this program run.
"""
- ret_code = attr.ib(init=False, default=0, repr=False)
- file_results = attr.ib(init=False, default=None, repr=False)
+ ret_code: int = attr.ib(init=False, default=0, repr=False)
+ file_results: list[InterrogateFileResult] = attr.ib(
+ init=False, default=None, repr=False
+ )
- def combine(self):
+ def combine(self) -> None:
"""Tally results from each file."""
for result in self.file_results:
self.covered += result.covered
@@ -106,30 +108,40 @@ class InterrogateCoverage:
:param config.InterrogateConfig conf: interrogation configuration.
:param tuple(str) excluded: tuple of files and directories to exclude
in assessing coverage.
+ :param list[str] extensions: additional file extensions to interrogate.
"""
- COMMON_EXCLUDE = [".tox", ".venv", "venv", ".git", ".hg"]
- VALID_EXT = [".py", ".pyi"] # someday in the future: .ipynb
-
- def __init__(self, paths, conf=None, excluded=None, extensions=None):
+ COMMON_EXCLUDE: Final[list[str]] = [".tox", ".venv", "venv", ".git", ".hg"]
+ VALID_EXT: Final[list[str]] = [
+ ".py",
+ ".pyi",
+ ] # someday in the future: .ipynb
+
+ def __init__(
+ self,
+ paths: list[str],
+ conf: config.InterrogateConfig | None = None,
+ excluded: tuple[str] | None = None,
+ extensions: tuple[str] | None = None,
+ ):
self.paths = paths
self.extensions = set(extensions or set())
self.extensions.add(".py")
self.config = conf or config.InterrogateConfig()
self.excluded = excluded or ()
- self.common_base = pathlib.Path("/")
- self.output_formatter = None
+ self.common_base = ""
self._add_common_exclude()
self.skipped_file_count = 0
+ self.output_formatter: utils.OutputFormatter
- def _add_common_exclude(self):
+ def _add_common_exclude(self) -> None:
"""Ignore common directories by default"""
for path in self.paths:
- self.excluded = self.excluded + tuple(
+ self.excluded = self.excluded + tuple( # type: ignore
os.path.join(path, i) for i in self.COMMON_EXCLUDE
)
- def _filter_files(self, files):
+ def _filter_files(self, files: list[str]) -> Iterator[str]:
"""Filter files that are explicitly excluded."""
for f in files:
has_valid_ext = any([f.endswith(ext) for ext in self.extensions])
@@ -139,11 +151,11 @@ def _filter_files(self, files):
basename = os.path.basename(f)
if basename == "__init__.py":
continue
- if any(fnmatch(f, exc + "*") for exc in self.excluded):
+ if any(fnmatch.fnmatch(f, exc + "*") for exc in self.excluded):
continue
yield f
- def get_filenames_from_paths(self):
+ def get_filenames_from_paths(self) -> list[str]:
"""Find all files to measure for docstring coverage."""
filenames = []
for path in self.paths:
@@ -177,7 +189,7 @@ def get_filenames_from_paths(self):
self.common_base = utils.get_common_base(filenames)
return filenames
- def _filter_nodes(self, nodes):
+ def _filter_nodes(self, nodes: list[visit.CovNode]) -> list[visit.CovNode]:
"""Remove empty modules when ignoring modules."""
is_empty = 1 == len(nodes)
if is_empty and self.config.ignore_module:
@@ -203,7 +215,9 @@ def _filter_nodes(self, nodes):
filtered.insert(0, module_node)
return filtered
- def _filter_inner_nested(self, nodes):
+ def _filter_inner_nested(
+ self, nodes: list[visit.CovNode]
+ ) -> list[visit.CovNode]:
"""Filter out children of ignored nested funcs/classes."""
nested_cls = [n for n in nodes if n.is_nested_cls]
inner_nested_nodes = [n for n in nodes if n.parent in nested_cls]
@@ -212,7 +226,7 @@ def _filter_inner_nested(self, nodes):
filtered_nodes = [n for n in filtered_nodes if n not in nested_cls]
return filtered_nodes
- def _set_google_style(self, nodes):
+ def _set_google_style(self, nodes: list[visit.CovNode]) -> None:
"""Apply Google-style docstrings for class coverage.
Update coverage of a class node if its `__init__` method has a
@@ -224,12 +238,14 @@ def _set_google_style(self, nodes):
"""
for node in nodes:
if node.node_type == "FunctionDef" and node.name == "__init__":
- if not node.covered and node.parent.covered:
+ if not node.covered and node.parent.covered: # type: ignore
setattr(node, "covered", True)
- elif node.covered and not node.parent.covered:
+ elif node.covered and not node.parent.covered: # type: ignore
setattr(node.parent, "covered", True)
- def _get_file_coverage(self, filename):
+ def _get_file_coverage(
+ self, filename: str
+ ) -> InterrogateFileResult | None:
"""Get coverage results for a particular file."""
with open(filename, encoding="utf-8") as f:
source_tree = f.read()
@@ -240,7 +256,7 @@ def _get_file_coverage(self, filename):
filtered_nodes = self._filter_nodes(visitor.nodes)
if len(filtered_nodes) == 0:
- return
+ return None
if self.config.ignore_nested_functions:
filtered_nodes = [
@@ -260,7 +276,7 @@ def _get_file_coverage(self, filename):
results.combine()
return results
- def _get_coverage(self, filenames):
+ def _get_coverage(self, filenames: list[str]) -> InterrogateResults:
"""Get coverage results."""
results = InterrogateResults()
file_results = []
@@ -273,19 +289,20 @@ def _get_coverage(self, filenames):
results.combine()
fail_under_str = str(self.config.fail_under)
- round_to = -decimal.Decimal(fail_under_str).as_tuple().exponent
+ fail_under_dec = decimal.Decimal(fail_under_str)
+ round_to = -fail_under_dec.as_tuple().exponent # type: ignore
if self.config.fail_under > round(results.perc_covered, round_to):
results.ret_code = 1
return results
- def get_coverage(self):
+ def get_coverage(self) -> InterrogateResults:
"""Get coverage results from files."""
filenames = self.get_filenames_from_paths()
return self._get_coverage(filenames)
- def _get_filename(self, filename):
+ def _get_filename(self, filename: str) -> str:
"""Get filename for output information.
If only one file is being interrogated, then ``self.common_base``
@@ -296,7 +313,9 @@ def _get_filename(self, filename):
return os.path.basename(filename)
return filename[len(self.common_base) + 1 :]
- def _get_detailed_row(self, node, filename):
+ def _get_detailed_row(
+ self, node: visit.CovNode, filename: str
+ ) -> list[str]:
"""Generate a row of data for the detailed view."""
filename = self._get_filename(filename)
@@ -313,14 +332,16 @@ def _get_detailed_row(self, node, filename):
status = "MISSED" if not node.covered else "COVERED"
return [name, status]
- def _create_detailed_table(self, combined_results):
+ def _create_detailed_table(
+ self, combined_results: InterrogateResults
+ ) -> list[list[str]]:
"""Generate table for the detailed view.
The detailed view shows coverage of each module, class, and
function/method.
"""
- def _sort_nodes(x):
+ def _sort_nodes(x: visit.CovNode) -> int:
"""Sort nodes by line number."""
lineno = getattr(x, "lineno", 0)
# lineno is "None" if module is empty
@@ -347,7 +368,7 @@ def _sort_nodes(x):
verbose_tbl.append(self.output_formatter.TABLE_SEPARATOR)
return verbose_tbl
- def _print_detailed_table(self, results):
+ def _print_detailed_table(self, results: InterrogateResults) -> None:
"""Print detailed table to the given output stream."""
detailed_table = self._create_detailed_table(results)
@@ -370,7 +391,9 @@ def _print_detailed_table(self, results):
self.output_formatter.tw.line(to_print)
self.output_formatter.tw.line()
- def _create_summary_table(self, combined_results):
+ def _create_summary_table(
+ self, combined_results: InterrogateResults
+ ) -> list[list[str]]:
"""Generate table for the summary view.
The summary view shows coverage for an overall file.
@@ -390,9 +413,9 @@ def _create_summary_table(self, combined_results):
perc_covered = f"{file_result.perc_covered:.0f}%"
row = [
filename,
- file_result.total,
- file_result.missing,
- file_result.covered,
+ str(file_result.total),
+ str(file_result.missing),
+ str(file_result.covered),
perc_covered,
]
table.append(row)
@@ -405,15 +428,15 @@ def _create_summary_table(self, combined_results):
total_perc_covered = f"{combined_results.perc_covered:.1f}%"
total_row = [
"TOTAL",
- combined_results.total,
- combined_results.missing,
- combined_results.covered,
+ str(combined_results.total),
+ str(combined_results.missing),
+ str(combined_results.covered),
total_perc_covered,
]
table.append(total_row)
return table
- def _print_summary_table(self, results):
+ def _print_summary_table(self, results: InterrogateResults) -> None:
"""Print summary table to the given output stream."""
summary_table = self._create_summary_table(results)
self.output_formatter.tw.sep(
@@ -431,7 +454,7 @@ def _print_summary_table(self, results):
self.output_formatter.tw.line(to_print)
@staticmethod
- def _sort_results(results):
+ def _sort_results(results: InterrogateResults) -> InterrogateResults:
"""Sort results by filename, directories first"""
all_filenames_map = {r.filename: r for r in results.file_results}
all_dirs = sorted(
@@ -459,7 +482,7 @@ def _sort_results(results):
results.file_results = sorted_res
return results
- def _get_header_base(self):
+ def _get_header_base(self) -> str:
"""Get common base directory for header of verbose output."""
base = self.common_base
if os.path.isfile(base):
@@ -468,7 +491,7 @@ def _get_header_base(self):
return base + "\\"
return base + "/"
- def _print_omitted_file_count(self, results):
+ def _print_omitted_file_count(self, results: InterrogateResults) -> None:
"""Print # of files omitted due to 100% coverage and --omit-covered.
:param InterrogateResults results: results of docstring coverage
@@ -499,7 +522,9 @@ def _print_omitted_file_count(self, results):
)
self.output_formatter.tw.line(to_print)
- def print_results(self, results, output, verbosity):
+ def print_results(
+ self, results: InterrogateResults, output: str | None, verbosity: int
+ ) -> None:
"""Print results to a given output stream.
:param InterrogateResults results: results of docstring coverage
diff --git a/src/interrogate/utils.py b/src/interrogate/utils.py
index f96dc65..d8cb942 100644
--- a/src/interrogate/utils.py
+++ b/src/interrogate/utils.py
@@ -1,6 +1,8 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Collection of general helper functions."""
+from __future__ import annotations
+
import contextlib
import functools
import os
@@ -9,16 +11,23 @@
import shutil
import sys
+from typing import IO, Any, Final, Iterator, Sequence
+
import colorama
import tabulate
+from click import Context, Parameter
from py import io as py_io
+from interrogate.config import InterrogateConfig
+
-IS_WINDOWS = sys.platform == "win32"
+IS_WINDOWS: Final[bool] = sys.platform == "win32"
-def parse_regex(ctx, param, values):
+def parse_regex(
+ ctx: Context, param: Parameter, values: list[str]
+) -> list[re.Pattern[str]] | None:
"""Compile a regex if given.
:param click.Context ctx: click command context.
@@ -32,12 +41,14 @@ def parse_regex(ctx, param, values):
``list`` of ``str``s.
"""
if not values:
- return
+ return None
return [re.compile(v) for v in values]
@contextlib.contextmanager
-def smart_open(filename=None, fmode=None):
+def smart_open(
+ filename: str | None = None, fmode: str = "w"
+) -> Iterator[IO[Any]]:
"""Context manager to handle both stdout & files in the same manner.
:param filename: Filename to open.
@@ -57,7 +68,7 @@ def smart_open(filename=None, fmode=None):
fh.close()
-def get_common_base(files):
+def get_common_base(files: Sequence[str | pathlib.Path]) -> str:
"""Find the common parent base path for a list of files.
For example, ``["/usr/src/app", "/usr/src/tests", "/usr/src/app2"]``
@@ -83,11 +94,11 @@ class OutputFormatter:
TERMINAL_WIDTH, _ = shutil.get_terminal_size((80, 20))
TABLE_SEPARATOR = ["---"]
- def __init__(self, config, file=None):
+ def __init__(self, config: InterrogateConfig, file: IO[Any] | None = None):
self.config = config
self.tw = py_io.TerminalWriter(file=file)
- def should_markup(self):
+ def should_markup(self) -> bool:
"""Return whether or not color markup should be added to output."""
if self.config.color is False:
return False
@@ -102,7 +113,7 @@ def should_markup(self):
return True
- def set_detailed_markup(self, padded_cells):
+ def set_detailed_markup(self, padded_cells: list[str]) -> list[str]:
"""Add markup specific to the detailed output section."""
if not self.should_markup():
return padded_cells
@@ -126,7 +137,7 @@ def set_detailed_markup(self, padded_cells):
return marked_up_padded_cells
- def set_summary_markup(self, padded_cells):
+ def set_summary_markup(self, padded_cells: list[str]) -> list[str]:
"""Add markup specific to the summary output section."""
if not self.should_markup():
return padded_cells
@@ -156,8 +167,12 @@ def set_summary_markup(self, padded_cells):
return marked_up_padded_cells
def _interrogate_line_formatter(
- self, padded_cells, colwidths, colaligns, table_type
- ):
+ self,
+ padded_cells: list[str],
+ colwidths: list[int],
+ colaligns: list[str],
+ table_type: str,
+ ) -> str:
"""Format rows of a table to fit terminal.
:param list(str) padded_cells: row where each cell is padded with
@@ -165,6 +180,9 @@ def _interrogate_line_formatter(
:param list(int) colwidths: list of widths, by column order.
:param list(str) colaligns: list of column alignment, by column
order. Possible values: ``"left"`` or ``"right"``
+ :param str table_type: Table type of either "detailed" (second
+ level of output verbosity), or "summary" (first level of
+ output verbosity).
:return: a formatted table row
:rtype: str
@@ -223,7 +241,7 @@ def _interrogate_line_formatter(
ret = sep + sep.join(to_join) + sep
return ret.rstrip()
- def get_table_formatter(self, table_type):
+ def get_table_formatter(self, table_type: str) -> tabulate.TableFormat:
"""Get a `tabulate` table formatter.
:param str table_type: Table type of either "detailed" (second
diff --git a/src/interrogate/visit.py b/src/interrogate/visit.py
index 1748bf0..e9aa906 100644
--- a/src/interrogate/visit.py
+++ b/src/interrogate/visit.py
@@ -1,11 +1,21 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""AST traversal for finding docstrings."""
+from __future__ import annotations
import ast
import os
+from typing import Union
+
import attr
+from interrogate.config import InterrogateConfig
+
+
+DocumentableFunc = Union[ast.AsyncFunctionDef, ast.FunctionDef]
+DocumentableFuncOrClass = Union[DocumentableFunc, ast.ClassDef]
+DocumentableNode = Union[DocumentableFuncOrClass, ast.Module]
+
@attr.s(eq=False)
class CovNode:
@@ -26,15 +36,15 @@ class CovNode:
:param CovNode _parent: parent node of current CovNode, if any.
"""
- name = attr.ib()
- path = attr.ib()
- level = attr.ib()
- lineno = attr.ib()
- covered = attr.ib()
- node_type = attr.ib()
- is_nested_func = attr.ib()
- is_nested_cls = attr.ib()
- parent = attr.ib()
+ name: str = attr.ib()
+ path: str = attr.ib()
+ level: int = attr.ib()
+ lineno: int | None = attr.ib()
+ covered: bool = attr.ib()
+ node_type: str = attr.ib()
+ is_nested_func: bool = attr.ib()
+ is_nested_cls: bool = attr.ib()
+ parent: CovNode | None = attr.ib()
class CoverageVisitor(ast.NodeVisitor):
@@ -44,21 +54,21 @@ class CoverageVisitor(ast.NodeVisitor):
:param config.InterrogateConfig config: configuration.
"""
- def __init__(self, filename, config):
+ def __init__(self, filename: str, config: InterrogateConfig):
self.filename = filename
- self.stack = []
- self.nodes = []
self.config = config
+ self.stack: list[CovNode] = []
+ self.nodes: list[CovNode] = []
@staticmethod
- def _has_doc(node):
+ def _has_doc(node: DocumentableNode) -> bool:
"""Return if node has docstrings."""
return (
ast.get_docstring(node) is not None
- and ast.get_docstring(node).strip() != ""
+ and ast.get_docstring(node).strip() != "" # type: ignore
)
- def _visit_helper(self, node):
+ def _visit_helper(self, node: DocumentableNode) -> None:
"""Recursively visit AST node for docstrings."""
if not hasattr(node, "name"):
node_name = os.path.basename(self.filename)
@@ -99,7 +109,7 @@ def _visit_helper(self, node):
self.stack.pop()
- def _is_nested_func(self, parent, node_type):
+ def _is_nested_func(self, parent: CovNode | None, node_type: str) -> bool:
"""Is node a nested func/method of another func/method."""
if parent is None:
return False
@@ -108,7 +118,7 @@ def _is_nested_func(self, parent, node_type):
return True
return False
- def _is_nested_cls(self, parent, node_type):
+ def _is_nested_cls(self, parent: CovNode | None, node_type: str) -> bool:
"""Is node a nested func/method of another func/method."""
if parent is None:
return False
@@ -120,7 +130,7 @@ def _is_nested_cls(self, parent, node_type):
return True
return False
- def _is_private(self, node):
+ def _is_private(self, node: DocumentableFuncOrClass) -> bool:
"""Is node private (i.e. __MyClass, __my_func)."""
if node.name.endswith("__"):
return False
@@ -128,7 +138,7 @@ def _is_private(self, node):
return False
return True
- def _is_semiprivate(self, node):
+ def _is_semiprivate(self, node: DocumentableFuncOrClass) -> bool:
"""Is node semiprivate (i.e. _MyClass, _my_func)."""
if node.name.endswith("__"):
return False
@@ -138,7 +148,7 @@ def _is_semiprivate(self, node):
return False
return True
- def _is_ignored_common(self, node):
+ def _is_ignored_common(self, node: DocumentableFuncOrClass) -> bool:
"""Commonly-shared ignore checkers."""
is_private = self._is_private(node)
is_semiprivate = self._is_semiprivate(node)
@@ -155,7 +165,7 @@ def _is_ignored_common(self, node):
return True
return False
- def _has_property_decorators(self, node):
+ def _has_property_decorators(self, node: DocumentableFuncOrClass) -> bool:
"""Detect if node has property get/setter/deleter decorators."""
if not hasattr(node, "decorator_list"):
return False
@@ -171,7 +181,7 @@ def _has_property_decorators(self, node):
return True
return False
- def _has_setters(self, node):
+ def _has_setters(self, node: DocumentableFuncOrClass) -> bool:
"""Detect if node has property get/setter decorators."""
if not hasattr(node, "decorator_list"):
return False
@@ -182,7 +192,7 @@ def _has_setters(self, node):
return True
return False
- def _has_overload_decorator(self, node):
+ def _has_overload_decorator(self, node: DocumentableFuncOrClass) -> bool:
"""Detect if node has a typing.overload decorator."""
if not hasattr(node, "decorator_list"):
return False
@@ -202,7 +212,7 @@ def _has_overload_decorator(self, node):
return True
return False
- def _is_func_ignored(self, node):
+ def _is_func_ignored(self, node: DocumentableFuncOrClass) -> bool:
"""Should the AST visitor ignore this func/method node."""
is_init = node.name == "__init__"
is_magic = all(
@@ -229,18 +239,18 @@ def _is_func_ignored(self, node):
return self._is_ignored_common(node)
- def _is_class_ignored(self, node):
+ def _is_class_ignored(self, node: DocumentableFuncOrClass) -> bool:
"""Should the AST visitor ignore this class node."""
return self._is_ignored_common(node)
- def visit_Module(self, node):
+ def visit_Module(self, node: DocumentableNode) -> None:
"""Visit module for docstrings.
:param ast.Module node: a module AST node.
"""
self._visit_helper(node)
- def visit_ClassDef(self, node):
+ def visit_ClassDef(self, node: DocumentableFuncOrClass) -> None:
"""Visit class for docstrings.
:param ast.ClassDef node: a class AST node.
@@ -249,7 +259,7 @@ def visit_ClassDef(self, node):
return
self._visit_helper(node)
- def visit_FunctionDef(self, node):
+ def visit_FunctionDef(self, node: DocumentableFuncOrClass) -> None:
"""Visit function or method for docstrings.
:param ast.FunctionDef node: a function/method AST node.
@@ -258,7 +268,7 @@ def visit_FunctionDef(self, node):
return
self._visit_helper(node)
- def visit_AsyncFunctionDef(self, node):
+ def visit_AsyncFunctionDef(self, node: DocumentableFuncOrClass) -> None:
"""Visit async function or method for docstrings.
:param ast.AsyncFunctionDef node: a async function/method AST node.
diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py
index 42627c2..559df4a 100644
--- a/tests/functional/__init__.py
+++ b/tests/functional/__init__.py
@@ -1,2 +1,2 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Yay functional tests for the ``interrogate`` package!"""
diff --git a/tests/functional/sample/__init__.py b/tests/functional/sample/__init__.py
index 3c0a94d..8cee339 100644
--- a/tests/functional/sample/__init__.py
+++ b/tests/functional/sample/__init__.py
@@ -1,2 +1,2 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Some init module docs"""
diff --git a/tests/functional/sample/child_sample/__init__.py b/tests/functional/sample/child_sample/__init__.py
index 55f709e..5a052c9 100644
--- a/tests/functional/sample/child_sample/__init__.py
+++ b/tests/functional/sample/child_sample/__init__.py
@@ -1,2 +1,2 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
# intentionally no docstrings here
diff --git a/tests/functional/sample/child_sample/child_sample_module.py b/tests/functional/sample/child_sample/child_sample_module.py
index 3d6c4e6..872073d 100644
--- a/tests/functional/sample/child_sample/child_sample_module.py
+++ b/tests/functional/sample/child_sample/child_sample_module.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
# intentionally no docstrings here
diff --git a/tests/functional/sample/full.py b/tests/functional/sample/full.py
index 9c97f90..bfc3b01 100644
--- a/tests/functional/sample/full.py
+++ b/tests/functional/sample/full.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Sample module-level docs"""
import typing
diff --git a/tests/functional/sample/partial.py b/tests/functional/sample/partial.py
index 663645c..96eb6e4 100644
--- a/tests/functional/sample/partial.py
+++ b/tests/functional/sample/partial.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Sample module-level docs"""
import typing
diff --git a/tests/functional/test_cli.py b/tests/functional/test_cli.py
index f4ae5b3..b03a555 100644
--- a/tests/functional/test_cli.py
+++ b/tests/functional/test_cli.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Functional tests for the CLI and implicitly interrogate/visit.py."""
import os
@@ -8,8 +8,7 @@
from click import testing
-from interrogate import cli
-from interrogate import config
+from interrogate import cli, config
HERE = os.path.abspath(os.path.join(os.path.abspath(__file__), os.path.pardir))
diff --git a/tests/functional/test_coverage.py b/tests/functional/test_coverage.py
index 47906d0..4401828 100644
--- a/tests/functional/test_coverage.py
+++ b/tests/functional/test_coverage.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Functional tests for interrogate/coverage.py."""
import os
@@ -6,8 +6,7 @@
import pytest
-from interrogate import config
-from interrogate import coverage
+from interrogate import config, coverage
HERE = os.path.abspath(os.path.join(os.path.abspath(__file__), os.path.pardir))
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
index 377a005..ca2174a 100644
--- a/tests/unit/__init__.py
+++ b/tests/unit/__init__.py
@@ -1,2 +1,2 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Yay unit tests for the ``interrogate`` package!"""
diff --git a/tests/unit/test_badge_gen.py b/tests/unit/test_badge_gen.py
index 8cf58f3..4ecca83 100644
--- a/tests/unit/test_badge_gen.py
+++ b/tests/unit/test_badge_gen.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Unit tests for interrogate/badge_gen.py module"""
import os
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 5421a13..df574e8 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Unit tests for interrogate/config.py module"""
import configparser
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index 64f9c79..68c897b 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Lynn Root
+# Copyright 2020-2024 Lynn Root
"""Unit tests for interrogate/utils.py module"""
import re
@@ -6,8 +6,7 @@
import pytest
-from interrogate import config
-from interrogate import utils
+from interrogate import config, utils
IS_WINDOWS = sys.platform in ("cygwin", "win32")
diff --git a/tox.ini b/tox.ini
index 313a7fc..eba49e3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,6 +5,7 @@ env_list =
docs,
lint,
manifest,
+ mypy,
coverage-report
[testenv]
@@ -18,6 +19,10 @@ extras =
png: png
commands = coverage run -m pytest {posargs}
+[testenv:mypy]
+extras =
+ mypy: typing
+commands = mypy src/
[testenv:lint]
base_python = python3.10