"""Error reporters."""
from __future__ import annotations
import importlib
import json
import types
from collections.abc import Callable
from typing import Any
from tabulate import tabulate_formats
from project_config.compat import importlib_metadata
from project_config.exceptions import ProjectConfigException
from project_config.reporters.base import BaseReporter
DEFAULT_REPORTER = "default"
PROJECT_CONFIG_REPORTERS_ENTRYPOINTS_GROUP = "project_config.reporters"
[docs]class UnparseableReporterError(ProjectConfigException):
"""Reporter can't be parsed."""
def __init__(self, reporter_id: str) -> None: # noqa: D107
super().__init__(
f"Reporter '{reporter_id}' can't be parsed. "
"See 'project-config --help' for more information.",
)
[docs]class InvalidThirdPartyReporterName(ProjectConfigException):
"""A third party reporter can't be loaded by his identifier."""
def __init__(self, reporter_id: str) -> None: # noqa: D107
super().__init__(
f"Reporter '{reporter_id}' not found. See all"
" available running 'project-config show reporters'",
)
[docs]class InvalidNotBasedThirdPartyReporter(ProjectConfigException):
"""Reporter not based on base reporter class.
All reporters must be based on the base reporter class
:py::class:``project_config.reporters.base.BaseReporter``.
"""
[docs]class InvalidThirdPartyReportersModule(ProjectConfigException):
"""Third party reporters module is invalid.
Third party reporters module must expose a color and a black/white
reporter.
"""
reporters = {
"default": "DefaultReporter",
"json": "JsonReporter",
"json:pretty": "JsonReporter",
"json:pretty4": "JsonReporter",
"toml": "TomlReporter",
"yaml": "YamlReporter",
"markdown": "GithubFlavoredMarkdownReporter",
"github-actions": "GithubFlavoredMarkdownReporter",
**{f"table:{fmt}": "TableReporter" for fmt in tabulate_formats},
}
reporters_modules = {
"json": "json_",
"markdown": "ghf_markdown",
"github-actions": "ghf_markdown",
}
[docs]def _parse_reporter_arguments(arguments_string: str) -> dict[str, Any]:
result: dict[str, str] = {}
for arg_value in arguments_string.split(";"):
key, value = arg_value.split("=", maxsplit=1)
result[key] = json.loads(value)
return result
[docs]def parse_reporter_id(value: str) -> tuple[str, dict[str, Any]]:
"""Parse a reporter identifier.
Returns the reporter name and the optional arguments for his class.
Args:
value (str): Reporter identifier.
"""
if ";" in value:
reporter_id, reporter_kwargs_string = value.split(";", maxsplit=1)
else:
reporter_id, reporter_kwargs_string = value, ""
if ":" in reporter_id:
reporter_name, fmt = reporter_id.split(":", maxsplit=1)
else:
reporter_name, fmt = reporter_id, None
if reporter_kwargs_string:
reporter_kwargs = _parse_reporter_arguments(reporter_kwargs_string)
else:
reporter_kwargs = {}
return reporter_name, {**reporter_kwargs, "fmt": fmt}
[docs]def get_reporter(
reporter_name: str,
reporter_kwargs: dict[str, Any],
color: bool | None,
rootdir: str,
only_hints: bool = False, # noqa: FBT001, FBT002
) -> Any:
"""Reporters factory.
Args:
reporter_name (str): Reporter identifier name.
reporter_kwargs (dict): Optional arguments for reporter class.
color (bool): Return the colorized version of the reporter,
if is implemented, using the black/white version as
a fallback.
rootdir (str): Root directory of the project.
only_hints (bool): If ``True``, only hints will be reported.
"""
try:
if reporter_name in reporters:
reporter_class_name = reporters[reporter_name]
else:
reporter_class_name = reporters[
f"{reporter_name}:{reporter_kwargs.get('fmt')}"
]
except KeyError:
# 3rd party reporter
third_party_reporters = ThirdPartyReporters()
reporter_module = third_party_reporters.load(reporter_name)
(
color_class_name,
bw_class_name,
) = third_party_reporters.validate_reporter_module(
reporter_module,
)
# validate both reporters in the class
for class_name in (color_class_name, bw_class_name):
third_party_reporters.validate_reporter_class(
getattr(reporter_module, class_name),
)
reporter_class_name = color_class_name if color else bw_class_name
Reporter = getattr(reporter_module, reporter_class_name)
third_party_reporters.validate_reporter_class(Reporter)
else:
reporter_module_name = reporters_modules.get(
reporter_name,
reporter_name,
)
reporter_module = importlib.import_module(
f"project_config.reporters.{reporter_module_name}",
)
if color in (True, None):
reporter_class_name = reporter_class_name.replace(
"Reporter",
"ColorReporter",
)
Reporter = getattr(reporter_module, reporter_class_name)
reporter_kwargs.update({"only_hints": only_hints})
return Reporter(rootdir, **reporter_kwargs)
[docs]class ThirdPartyReporters:
"""Third party reporters loader from entrypoints."""
# allow to reset the instance, just for testing purposes
instance: ThirdPartyReporters | None = None
def __new__(cls) -> ThirdPartyReporters: # noqa: D102
if cls.instance is None:
cls.instance = super().__new__(cls)
return cls.instance
def __init__(self) -> None: # noqa: D107
self.reporters_loaders: dict[
str,
Callable[[], types.ModuleType],
] = {}
self.loaded_reporters: dict[str, types.ModuleType] = {}
self._prepare_third_party_reporters()
@property
def ids(self) -> list[str]:
"""Returns the identifiers of the 3rd party reporters."""
return list(self.reporters_loaders.keys())
[docs] def _prepare_third_party_reporters(self) -> None:
for reporter_entrypoint in importlib_metadata.entry_points(
group=PROJECT_CONFIG_REPORTERS_ENTRYPOINTS_GROUP,
):
self.reporters_loaders[reporter_entrypoint.name] = (
reporter_entrypoint.load
)
[docs] def load(self, reporter_name: str) -> types.ModuleType:
"""Load a third party reporter.
Args:
reporter_name (str): Reporter module entrypoint name.
"""
if reporter_name not in self.loaded_reporters:
try:
reporter_impl = self.reporters_loaders[reporter_name]
except KeyError:
# A third party reporter was not found
raise InvalidThirdPartyReporterName(reporter_name) from None
else:
self.loaded_reporters[reporter_name] = reporter_impl()
return self.loaded_reporters[reporter_name]
[docs] def validate_reporter_module(
self,
reporter_module: types.ModuleType,
) -> tuple[str, str]:
"""Validate a reporter module.
Returns black/white and color reporter class names if the reporters
module is valid.
Args:
reporter_module (type): Reporters module to validate.
"""
color_reporter_class_name = ""
bw_reporter_class_name = ""
for object_name in dir(reporter_module):
if object_name.startswith(("_", "Base")):
continue
if "ColorReporter" in object_name:
if not color_reporter_class_name:
color_reporter_class_name = object_name
else:
raise InvalidThirdPartyReportersModule(
"Multiple public color reporters found in module"
f" '{reporter_module.__name__}'",
)
elif "Reporter" in object_name:
if not bw_reporter_class_name:
bw_reporter_class_name = object_name
else:
raise InvalidThirdPartyReportersModule(
"Multiple public black/white reporters found in"
f" module '{reporter_module.__name__}'",
)
if not color_reporter_class_name:
raise InvalidThirdPartyReportersModule(
"No color reporter found in module"
f" '{reporter_module.__name__}'",
)
if not bw_reporter_class_name:
raise InvalidThirdPartyReportersModule(
"No black/white reporter found in module"
f" '{reporter_module.__name__}'",
)
return color_reporter_class_name, bw_reporter_class_name
[docs] def validate_reporter_class(self, reporter_class: Any) -> None:
"""Validate a reporter class.
Args:
reporter_class (type): Reporter class to validate.
"""
if not issubclass(reporter_class, BaseReporter):
raise InvalidNotBasedThirdPartyReporter(
f"Reporter class '{reporter_class.__name__}' is not"
" a subclass of BaseReporter",
)