#!/usr/bin/env python3
#
# __init__.py
"""
A tool to check all modules can be correctly imported.
"""
#
# Copyright © 2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.
#
# stdlib
import functools
import importlib
import importlib.machinery
import importlib.util
import traceback
from typing import Any, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Tuple, Union, cast
# 3rd party
import click
import dom_toml
from click.globals import resolve_color_default
from consolekit.terminal_colours import Back, Style
from domdf_python_tools.doctools import prettify_docstrings
from domdf_python_tools.paths import PathPlus
from domdf_python_tools.stringlist import StringList
from domdf_python_tools.typing import PathLike
from domdf_python_tools.utils import redirect_output
from domdf_python_tools.words import Plural
from packaging.markers import Marker
from typing_extensions import TypedDict
__all__ = (
"load_toml",
"check_module",
"paths_to_modules",
"ConfigDict",
"ImportChecker",
"OK",
"Error",
)
__author__: str = "Dominic Davis-Foster"
__copyright__: str = "2021 Dominic Davis-Foster"
__license__: str = "MIT License"
__version__: str = "0.5.0"
__email__: str = "dominic@davis-foster.co.uk"
_module = Plural("module", "modules")
[docs]class ConfigDict(TypedDict, total=False):
"""
:class:`typing.TypedDict` representing the configuration mapping parsed from ``pyproject.toml`` or similar.
"""
#: List of modules to always try to import.
always: List[str]
#: Mapping of :pep:`508` markers to lists of imports to try to import if the markers evaluate to :py:obj:`True`.
only_if: Mapping[str, List[str]]
#: Configuration for ``importcheck``.
config: Dict[str, Any]
[docs]def load_toml(filename: PathLike) -> ConfigDict:
"""
Load the ``importcheck`` configuration mapping from the given TOML file.
:param filename:
"""
config = dom_toml.load(filename)
if "importcheck" in config:
return cast(ConfigDict, config["importcheck"])
elif "tool" in config and "importcheck" in config["tool"]:
return cast(ConfigDict, config["tool"]["importcheck"])
else:
raise KeyError("No such table 'importcheck' or 'tool.importcheck'")
def evaluate_markers(config: ConfigDict) -> List[str]:
"""
Evaluate the markers in the ``only_if`` key and return a list of all modules to try to import.
:param config:
"""
modules_to_check: List[str] = []
if "always" in config:
modules_to_check.extend(config["always"])
if "only_if" in config:
for marker, modules in config["only_if"].items():
if Marker(marker).evaluate():
modules_to_check.extend(modules)
return modules_to_check
[docs]@prettify_docstrings
class OK(NamedTuple):
"""
Returned by :func:`~.check_module` if the module is successfully imported.
"""
#: The name of the module being checked.
module: str
@property
def stdout(self): # noqa: D102,MAN002
raise NotImplementedError
@property
def stderr(self): # noqa: D102,MAN002
raise NotImplementedError
[docs] def __bool__(self) -> bool:
"""
:class:`~.OK` objects always evaluate as :py:obj:`False`.
"""
return False
[docs]@prettify_docstrings
class Error(NamedTuple):
"""
Returned by :func:`~.check_module` if the module could not be successfully imported.
"""
#: The name of the module being checked.
module: str
stdout: str
"""
The standard output from importing the module.
This may also contain standard error if the streams are combined by :func:`~.check_module`
"""
stderr: str
"""
Standard error generated by importing the module.
This may also contain standard out if the streams are combined by :func:`~.check_module`.
"""
[docs] def __bool__(self) -> bool:
"""
:class:`~.Error` objects always evaluate as :py:obj:`True`.
"""
return True
[docs]def check_module(module: str, combine_output: bool = False) -> Union[OK, Error]:
"""
Try to import ``module``, otherwise handle the resulting error.
:param module:
:param combine_output: If :py:obj:`True` ``stderr`` is combined with ``stdout``.
"""
with redirect_output(combine_output) as (stdout, stderr):
try:
importlib.import_module(module)
return OK(module)
except Exception as e:
traceback_frames = traceback.extract_tb(e.__traceback__)
tb_e = traceback.TracebackException(
type(e),
e,
e.__traceback__,
)
if traceback_frames[0].filename == __file__:
del traceback_frames[0]
buf = ['Traceback (most recent call last):\n']
buf.extend(traceback.format_list(traceback_frames))
while buf[-1] == '\n': # pragma: no cover
del buf[-1] # pylint: disable=loop-invariant-statement
buf.extend(tb_e.format_exception_only())
click.echo(''.join(buf), file=stderr)
return Error(module, stdout.getvalue(), stderr.getvalue())
[docs]def paths_to_modules(*paths: PathLike) -> Iterator[str]:
r"""
Convert filesystem paths into dotted import names.
For example, ``foo/bar.py`` becomes ``foo.bar``.
.. versionadded:: 0.3.0
:param \*paths: The paths to convert.
"""
for path in paths:
path = PathPlus(path)
if path.is_file() and path.suffix == ".py":
path = path.with_suffix('')
yield '.'.join(path.parts)
[docs]class ImportChecker:
r"""
Class for checking modules can be imported.
.. versionadded:: 0.3.0
:param modules: The list of modules to be checked.
:param show: Whether to show stdout and stderr generated from imports.
:param colour: Whether to use coloured output.
.. autosummary-widths:: 5/16
"""
def __init__(
self,
modules: Iterable[str],
*,
show: bool = False,
colour: bool = False,
):
#: The list of modules to be checked.
self.modules: List[str] = list(modules)
#: Dictionary holding statistics about passing/failing imports.
self.stats: Dict[str, int] = {"passed": 0, "failed": 0}
#: Whether to show stdout and stderr generated from imports.
self.show: bool = show
#: Whether to use coloured output.
self.colour: bool = colour
[docs] def check_modules(self) -> Iterator[Tuple[str, int]]:
"""
Checks modules can be imported.
:returns: An iterator of 2-element tuples comprising the name of the module and the import status:
0. The module was imported successfully.
1. The module could not be imported. If :attr:`~.show` is :py:obj:`True` the traceback will be shown.
"""
longest_name = 15
echo = functools.partial(click.echo, color=resolve_color_default(self.colour))
if self.modules:
longest_name += max(map(len, self.modules))
else:
return
for module_name in self.modules:
echo(Style.BRIGHT(f"Checking {module_name!r}".ljust(longest_name, '.')), nl=False)
ret = check_module(module_name, combine_output=True)
if ret:
echo(Back.RED("Failed"))
self.stats["failed"] += 1 # pylint: disable=loop-invariant-statement
if self.show:
echo(Style.BRIGHT("Captured output:"))
stdout = StringList(ret.stdout)
stdout.blankline(ensure_single=True)
echo(stdout)
yield module_name, 1
else:
echo(Back.GREEN("Passed"))
self.stats["passed"] += 1 # pylint: disable=loop-invariant-statement
yield module_name, 0