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