"""General plots module."""
import abc
import logging
import typing as tp
from copy import deepcopy
from pathlib import Path
from typing import final
import click
from varats.paper_mgmt.artefacts import Artefact, ArtefactFileInfo
from varats.ts_utils.artefact_util import convert_kwargs
from varats.ts_utils.cli_util import (
make_cli_option,
CLIOptionTy,
ConfigOption,
OptionTy,
COGetter,
COGetterV,
add_cli_options,
cli_yn_choice,
)
from varats.utils.settings import vara_cfg
if tp.TYPE_CHECKING:
from rich.progress import Progress, TaskID # pylint: disable=unused-import
import varats.plot.plot # pylint: disable=W0611
LOG = logging.getLogger(__name__)
[docs]
class CommonPlotOptions():
"""
Options common to all plots.
These options are handled by the :class:`PlotGenerator` base class and are
not passed down to specific plot generators.
Args:
view: if `True`, view the plot instead of writing it to a file
plot_dir: directory to write plots to
(relative to config value 'plots/plot_dir')
file_type: the file type for the written plot file
dry_run: if ``True``, do not generate any files
"""
def __init__(
self, view: bool, plot_dir: Path, file_type: str, dry_run: bool
):
self.view = view
# Will be overridden when generating artefacts
self.plot_base_dir = Path(str(vara_cfg()['plots']['plot_dir']))
self.plot_dir = plot_dir
self.file_type = file_type
self.dry_run = dry_run
[docs]
@staticmethod
def from_kwargs(**kwargs: tp.Any) -> 'CommonPlotOptions':
"""Construct a ``CommonPlotOptions`` object from a kwargs dict."""
return CommonPlotOptions(
kwargs.get("view", False), Path(kwargs.get("plot_dir", ".")),
kwargs.get("file_type", "svg"), kwargs.get("dry_run", False)
)
__options = [
make_cli_option(
"-v",
"--view",
is_flag=True,
help="View the plot instead of saving it to a file."
),
make_cli_option(
"--file-type",
type=click.Choice(["png", "svg", "pdf"]),
default="svg",
help="File type for the plot."
),
make_cli_option(
"--plot-dir",
type=click.Path(path_type=Path),
default=Path("."),
help="Set the directory the plots will be written to "
"(relative to config value 'plots/plot_dir')."
),
make_cli_option(
"--dry-run",
is_flag=True,
help="Only log plots that would be generated but do not generate."
"Useful for debugging plot generators."
),
]
[docs]
@classmethod
def cli_options(cls, command: tp.Any) -> tp.Any:
"""
Decorate a command with common plot CLI options.
This function can be used as a decorator.
Args:
command: the command to decorate
Returns:
the decorated command
"""
return add_cli_options(command, *cls.__options)
[docs]
def get_dict(self) -> tp.Dict[str, tp.Any]:
"""
Create a dict representation for this object.
It holds that
``options == CommonPlotOptions.from_kwargs(**options.get_dict())``.
Returns:
a dict representation of this object
"""
return {
"view": self.view,
"file_type": self.file_type,
"plot_dir": self.plot_dir,
"dry_run": self.dry_run
}
[docs]
class PlotConfig():
"""
Class with parameters that influence a plot's appearance.
Instances should typically be created with the :func:`from_kwargs` function.
"""
def __init__(self, view: bool, *options: ConfigOption[tp.Any]) -> None:
self.__view = view
self.__options = deepcopy(self._option_decls)
for option in options:
self.__options[option.name] = option
_option_decls: tp.Dict[str, ConfigOption[tp.Any]] = {
decl.name: decl for decl in tp.cast(
tp.List[ConfigOption[tp.Any]], [
ConfigOption(
"fig_title",
default="",
help_str="The title of the plot figure."
),
ConfigOption(
"font_size",
default=10,
view_default=10,
help_str="The font size of the plot figure."
),
ConfigOption(
"width",
default=1500,
view_default=1500,
help_str="The width of the resulting plot file."
),
ConfigOption(
"height",
default=1000,
view_default=1000,
help_str="The height of the resulting plot file."
),
ConfigOption(
"legend_title",
default="",
help_str="The title of the legend."
),
ConfigOption(
"legend_size",
default=2,
view_default=8,
help_str="The size of the legend."
),
ConfigOption(
"show_legend",
default=False,
help_str="If present, show the legend."
),
ConfigOption(
"line_width",
default=0.25,
view_default=1,
help_str="The width of the plot line(s)."
),
ConfigOption(
"x_tick_size",
default=2,
view_default=10,
help_str="The size of the x-ticks."
),
ConfigOption(
"label_size",
default=2,
view_default=2,
help_str="The label size of CVE/bug annotations."
),
ConfigOption(
"dpi", default=1200, help_str="The dpi of the plot."
)
]
)
}
def __option_getter(self,
option: ConfigOption[OptionTy]) -> COGetter[OptionTy]:
"""Creates a getter for options with no view default."""
def get_value(default: tp.Optional[OptionTy] = None) -> OptionTy:
return option.value_or_default(self.__view, default)
return get_value
def __option_getter_v(
self, option: ConfigOption[OptionTy]
) -> COGetterV[OptionTy]:
"""Creates a getter for options with view default."""
def get_value(
default: tp.Optional[OptionTy] = None,
view_default: tp.Optional[OptionTy] = None
) -> OptionTy:
return option.value_or_default(self.__view, default, view_default)
return get_value
@property
def fig_title(self) -> COGetter[str]:
return self.__option_getter(self.__options["fig_title"])
@property
def font_size(self) -> COGetterV[int]:
return self.__option_getter_v(self.__options["font_size"])
@property
def width(self) -> COGetterV[int]:
return self.__option_getter_v(self.__options["width"])
@property
def height(self) -> COGetterV[int]:
return self.__option_getter_v(self.__options["height"])
@property
def legend_title(self) -> COGetter[str]:
return self.__option_getter(self.__options["legend_title"])
@property
def legend_size(self) -> COGetterV[int]:
return self.__option_getter_v(self.__options["legend_size"])
@property
def show_legend(self) -> COGetter[bool]:
return self.__option_getter(self.__options["show_legend"])
@property
def line_width(self) -> COGetterV[float]:
return self.__option_getter_v(self.__options["line_width"])
@property
def x_tick_size(self) -> COGetterV[int]:
return self.__option_getter_v(self.__options["x_tick_size"])
@property
def label_size(self) -> COGetterV[int]:
return self.__option_getter_v(self.__options["label_size"])
@property
def dpi(self) -> COGetter[int]:
return self.__option_getter(self.__options["dpi"])
[docs]
@classmethod
def from_kwargs(cls, view: bool, **kwargs: tp.Any) -> 'PlotConfig':
"""
Instantiate a ``PlotConfig`` object with values from the given kwargs.
Args:
**kwargs: a dict containing values to be used by this config
Returns:
a plot config object with values from the kwargs
"""
return PlotConfig(
view, *[
option_decl.with_value(kwargs[option_decl.name])
for option_decl in cls._option_decls.values()
if option_decl.name in kwargs
]
)
[docs]
@classmethod
def cli_options(cls, command: tp.Any) -> tp.Any:
"""
Decorate a command with plot config CLI options.
This function can be used as a decorator.
Args:
command: the command to decorate
Returns:
the decorated command
"""
return add_cli_options(
command,
*[option.to_cli_option() for option in cls._option_decls.values()]
)
[docs]
def get_dict(self) -> tp.Dict[str, tp.Any]:
"""
Create a dict representation from this plot config.
The dict only contains options for which values were explicitly set.
It holds that ``config == PlotConfig.from_kwargs(**config.get_dict())``.
Returns:
a dict representation of this plot config
"""
return {
option.name: option.value
for option in self.__options.values()
if option.value
}
[docs]
class PlotGeneratorFailed(Exception):
"""Exception for plot generator related errors."""
def __init__(self, message: str):
super().__init__()
self.message = message
[docs]
class PlotGenerator(abc.ABC):
"""
Superclass for all plot generators.
A plot generator is responsible for generating one or more plots.
Subclasses are automatically registered if they reside in the
``varats.plots`` package and must override the function
:meth:`generate` so that it returns one or more plot instances that should
be generated.
The generation itself (i.e., saving or displaying plots) is handeled by the
`call` operator (which should not be overridden!).
Creating a plot generator class requires to provide additional parameters in
the class definition, e.g.::
class MyPlotGenerator(
PlotGenerator,
generator_name="my_generator", # generator name as shown by CLI
options=[] # put CLI option declarations here
):
...
"""
GENERATORS: tp.Dict[str, tp.Type['PlotGenerator']] = {}
"""Registry for concrete plot generators."""
NAME: str
"""Name of the concrete generator class (set automatically)."""
OPTIONS: tp.List[CLIOptionTy]
"""Plot generator CLI Options (set automatically)."""
def __init__(self, plot_config: PlotConfig, **plot_kwargs: tp.Any):
self.__plot_config = plot_config
self.__plot_kwargs = plot_kwargs
@classmethod
def __init_subclass__(
cls, *, generator_name: str, options: tp.List[CLIOptionTy],
**kwargs: tp.Any
) -> None:
"""
Register concrete plot generators.
Args:
generator_name: plot generator name as shown by the CLI
plot: plot class used by the generator
options: command line options needed by the generator
"""
super().__init_subclass__(**kwargs)
cls.NAME = generator_name
cls.OPTIONS = options
cls.GENERATORS[generator_name] = cls
[docs]
@staticmethod
def get_plot_generator_types_help_string() -> str:
"""
Generates help string for visualizing all available plots.
Returns:
a help string that contains all available plot names.
"""
return "The following plot generators are available:\n " + "\n ".join(
list(PlotGenerator.GENERATORS)
)
[docs]
@staticmethod
def get_class_for_plot_generator_type(
plot_generator_type_name: str
) -> tp.Type['PlotGenerator']:
"""
Get the class for plot from the plot registry.
Args:
plot_generator_type_name: name of the plot generator
Returns:
the class for the plot generator
"""
if plot_generator_type_name not in PlotGenerator.GENERATORS:
raise LookupError(
f"Unknown plot generator '{plot_generator_type_name}'.\n" +
PlotGenerator.get_plot_generator_types_help_string()
)
plot_cls = PlotGenerator.GENERATORS[plot_generator_type_name]
return plot_cls
@property
def plot_config(self) -> PlotConfig:
"""Options that influence a plot's appearance."""
return self.__plot_config
@property
def plot_kwargs(self) -> tp.Dict[str, tp.Any]:
"""Plot-specific options."""
return self.__plot_kwargs
[docs]
@abc.abstractmethod
def generate(self) -> tp.List['varats.plot.plot.Plot']:
"""Create the plot instance(s) that should be generated."""
@final
def __call__(
self,
common_options: CommonPlotOptions,
progress: tp.Optional["Progress"] = None,
task_id: tp.Optional["TaskID"] = None
) -> None:
"""
Generate the plots as specified by this generator.
Args:
common_options: common options to use for the plot(s)
"""
plot_dir = common_options.plot_base_dir / common_options.plot_dir
if not plot_dir.exists():
plot_dir.mkdir(parents=True)
plots = self.generate()
if len(plots) > 1 and common_options.view:
common_options.view = cli_yn_choice(
f"Do you really want to view all {len(plots)} plots? "
f"If you answer 'no', the plots will still be generated.", "n"
)
if progress:
if task_id is None:
task_id = progress.add_task(
total=len(plots), description=f"Building {self.NAME}"
)
else:
progress.update(task_id, total=len(plots))
for plot in plots:
if common_options.dry_run:
LOG.info(repr(plot))
continue
if common_options.view:
plot.show()
else:
plot.save(plot_dir, filetype=common_options.file_type)
if progress and task_id is not None:
progress.advance(task_id)
[docs]
class PlotArtefact(Artefact, artefact_type="plot", artefact_type_version=2):
"""
An artefact defining a :class:`~varats.plot.plot.Plot`.
Args:
name: name of this artefact
output_dir: output dir relative to config value
'artefacts/artefacts_dir'
plot_generator_type: the
:attr:`type of plot<varats.plot.plots.PlotGenerator>`
to use
file_format: the file format of the generated plot
kwargs: additional arguments that will be passed to the plot class
"""
def __init__(
self, name: str, output_dir: Path, plot_generator_type: str,
common_options: CommonPlotOptions, plot_config: PlotConfig,
**kwargs: tp.Any
) -> None:
super().__init__(name, output_dir)
self.__plot_generator_type = plot_generator_type
self.__plot_type_class = \
PlotGenerator.get_class_for_plot_generator_type(
self.__plot_generator_type
)
self.__common_options = common_options
self.__common_options.plot_base_dir = Artefact.base_output_dir()
self.__common_options.plot_dir = output_dir
self.__plot_config = plot_config
self.__plot_kwargs = kwargs
@property
def plot_generator_type(self) -> str:
"""The type of plot generator used to generate this artefact."""
return self.__plot_generator_type
@property
def plot_generator_class(self) -> tp.Type[PlotGenerator]:
"""The class associated with :func:`plot_generator_type`."""
return self.__plot_type_class
@property
def common_options(self) -> CommonPlotOptions:
"""Options that are available to all plots."""
return self.__common_options
@property
def plot_config(self) -> PlotConfig:
"""Options that influence the visual representation of a plot."""
return self.__plot_config
@property
def plot_kwargs(self) -> tp.Any:
"""Additional arguments that will be passed to the plot."""
return self.__plot_kwargs
[docs]
def get_dict(self) -> tp.Dict[str, tp.Any]:
"""
Create a dict representation for this object.
Returns:
a dict representation of this object
"""
artefact_dict = super().get_dict()
artefact_dict['plot_generator'] = self.__plot_generator_type
artefact_dict['plot_config'] = self.__plot_config.get_dict()
artefact_dict = {
**self.__common_options.get_dict(),
**convert_kwargs(
self.plot_generator_class.OPTIONS,
self.__plot_kwargs,
to_string=True
),
**artefact_dict
}
artefact_dict.pop("plot_dir") # duplicate of Artefact's output_path
return artefact_dict
[docs]
@staticmethod
def create_artefact(
name: str, output_dir: Path, **kwargs: tp.Any
) -> 'Artefact':
"""
Create an artefact instance from the given information.
Args:
name: the name of the artefact
output_dir: the output directory for the artefact
**kwargs: artefact-specific arguments
Returns:
an artefact instance
"""
plot_generator_type = kwargs.pop('plot_generator')
common_options = CommonPlotOptions.from_kwargs(**kwargs)
plot_config = PlotConfig.from_kwargs(
common_options.view, **kwargs.pop("plot_config", {})
)
return PlotArtefact(
name, output_dir, plot_generator_type, common_options, plot_config,
**convert_kwargs(
PlotGenerator.
get_class_for_plot_generator_type(plot_generator_type).OPTIONS,
kwargs,
to_string=False
)
)
[docs]
@staticmethod
def from_generator(
name: str, generator: PlotGenerator, common_options: CommonPlotOptions
) -> 'PlotArtefact':
"""
Create a plot artefact from a generator.
Args:
name: name for the artefact
generator: generator class to use for the artefact
common_options: common plot options
Returns:
an instantiated plot artefact
"""
return PlotArtefact(
name, common_options.plot_dir, generator.NAME, common_options,
generator.plot_config, **generator.plot_kwargs
)
[docs]
def generate_artefact(
self, progress: tp.Optional["Progress"] = None
) -> None:
"""Generate the specified plot(s)."""
task_id = None
if progress:
task_id = progress.add_task(description=f"Building {self.name}")
generator_instance = self.plot_generator_class(
self.plot_config, **self.__plot_kwargs
)
generator_instance(self.common_options, progress, task_id)
[docs]
def get_artefact_file_infos(self) -> tp.List[ArtefactFileInfo]:
"""
Retrieve information about files generated by this artefact.
Returns:
a list of file info objects
"""
generator_instance = self.plot_generator_class(
self.plot_config, **self.__plot_kwargs
)
return [
ArtefactFileInfo(
plot.plot_file_name(self.common_options.file_type),
plot.plot_kwargs.get("case_study", None)
) for plot in generator_instance.generate()
]