"""General tables module."""
import abc
import logging
import typing as tp
from copy import deepcopy
from enum import Enum
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,
add_cli_options,
CLIOptionTy,
ConfigOption,
OptionTy,
COGetter,
COGetterV,
cli_yn_choice,
)
from varats.ts_utils.click_param_types import EnumChoice
from varats.utils.settings import vara_cfg
if tp.TYPE_CHECKING:
from rich.progress import Progress, TaskID # pylint: disable=unused-import
import varats.table.table # pylint: disable=unused-import
LOG = logging.getLogger(__name__)
[docs]
class CommonTableOptions():
"""
Options common to all tables.
These options are handled by the :class:`TableGenerator` base class and are
not passed down to specific table generators.
Args:
view: if `True`, view the table instead of writing it to a file
table_dir: directory to write tables to
(relative to config value 'tables/table_dir')
table_format: the format for the written table file
dry_run: if ``True``, do not generate any files
"""
def __init__(
self, view: bool, table_dir: Path, table_format: TableFormat,
wrap_table: bool, dry_run: bool
):
self.view = view
# Will be overridden when generating artefacts
self.table_base_dir = Path(str(vara_cfg()['tables']['table_dir']))
self.table_dir = table_dir
self.table_format = table_format
self.wrap_table = wrap_table
self.dry_run = dry_run
[docs]
@staticmethod
def from_kwargs(**kwargs: tp.Any) -> 'CommonTableOptions':
"""Construct a ``CommonTableOptions`` object from a kwargs dict."""
table_format = kwargs.get("table_format", TableFormat.PLAIN)
if isinstance(table_format, str):
table_format = TableFormat[table_format]
return CommonTableOptions(
kwargs.get("view", False), Path(kwargs.get("table_dir", ".")),
table_format, kwargs.get("wrap_table", False),
kwargs.get("dry_run", False)
)
__options = [
make_cli_option(
"-v",
"--view",
is_flag=True,
help="View the table instead of saving it to a file."
),
make_cli_option(
"--table-dir",
type=click.Path(path_type=Path),
default=Path("."),
help="Set the directory the tables will be written to "
"(relative to config value 'tables/table_dir')."
),
make_cli_option(
"--table-format",
type=EnumChoice(TableFormat, case_sensitive=False),
default="PLAIN",
help="Format for the table."
),
make_cli_option(
"--wrap-table",
is_flag=True,
help="Wrap tables inside a complete latex document."
),
make_cli_option(
"--dry-run",
is_flag=True,
help="Only log tables that would be generated but do not "
"generate."
"Useful for debugging table generators."
),
]
[docs]
@classmethod
def cli_options(cls, command: tp.Any) -> tp.Any:
"""
Decorate a command with common table 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 == CommonTableOptions.from_kwargs(**options.get_dict())``.
Returns:
a dict representation of this object
"""
return {
"view": self.view,
"table_format": self.table_format.name,
"wrap_table": self.wrap_table,
"table_dir": self.table_dir,
"dry_run": self.dry_run
}
[docs]
class TableConfig():
"""
Class with parameters that influence a table'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(
"font_size",
default=10,
view_default=10,
help_str="The font size of the table."
),
ConfigOption(
"fig_title", default="", help_str="The title of the table."
),
ConfigOption(
"line_width",
default=0.25,
view_default=1,
help_str="The width of the table line(s)."
)
]
)
}
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 line_width(self) -> COGetterV[float]:
return self.__option_getter_v(self.__options["line_width"])
[docs]
@classmethod
def from_kwargs(cls, view: bool, **kwargs: tp.Any) -> 'TableConfig':
"""
Instantiate a ``TableConfig`` object with values from the given kwargs.
Args:
**kwargs: a dict containing values to be used by this config
Returns:
a table config object with values from the kwargs
"""
return TableConfig(
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 table 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 table config.
The dict only contains options for which values were explicitly set.
It holds that
``config == TableConfig.from_kwargs(**config.get_dict())``.
Returns:
a dict representation of this table config
"""
return {
option.name: option.value
for option in self.__options.values()
if option.value
}
[docs]
class TableGeneratorFailed(Exception):
"""Exception for table generator related errors."""
def __init__(self, message: str):
super().__init__()
self.message = message
[docs]
class TableGenerator(abc.ABC):
"""
Superclass for all table generators.
A table generator is responsible for generating one or more tables.
Subclasses are automatically registered if they reside in the
``varats.tables`` package and must override the function
:meth:`generate` so that it returns one or more table instances that should
be generated.
The generation itself (i.e., saving or displaying tables) is handled by the
`call` operator (which should not be overridden!).
Creating a table generator class requires to provide additional parameters
in the class definition, e.g.::
class MyTableGenerator(
TableGenerator,
generator_name="my_generator", # generator name as shown by CLI
options=[] # put CLI option declarations here
):
...
"""
GENERATORS: tp.Dict[str, tp.Type['TableGenerator']] = {}
"""Registry for concrete table generators."""
NAME: str
"""Name of the concrete generator class (set automatically)."""
OPTIONS: tp.List[CLIOptionTy]
"""Table generator CLI Options (set automatically)."""
def __init__(self, table_config: TableConfig, **table_kwargs: tp.Any):
self.__table_config = table_config
self.__table_kwargs = table_kwargs
@classmethod
def __init_subclass__(
cls, *, generator_name: str, options: tp.List[CLIOptionTy],
**kwargs: tp.Any
) -> None:
"""
Register concrete table generators.
Args:
generator_name: table generator name as shown by the CLI
table: table 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_table_generator_types_help_string() -> str:
"""
Generates help string for visualizing all available tables.
Returns:
a help string that contains all available table names.
"""
return "The following table generators are available:\n " + \
"\n ".join(list(TableGenerator.GENERATORS))
[docs]
@staticmethod
def get_class_for_table_generator_type(
table_generator_type_name: str
) -> tp.Type['TableGenerator']:
"""
Get the class for table from the table registry.
Args:
table_generator_type_name: name of the table generator
Returns:
the class for the table generator
"""
if table_generator_type_name not in TableGenerator.GENERATORS:
raise LookupError(
f"Unknown table generator '{table_generator_type_name}'.\n" +
TableGenerator.get_table_generator_types_help_string()
)
table_cls = TableGenerator.GENERATORS[table_generator_type_name]
return table_cls
@property
def table_config(self) -> TableConfig:
"""Options that influence a table's appearance."""
return self.__table_config
@property
def table_kwargs(self) -> tp.Dict[str, tp.Any]:
"""Table-specific options."""
return self.__table_kwargs
[docs]
@abc.abstractmethod
def generate(self) -> tp.List['varats.table.table.Table']:
"""Create the table instance(s) that should be generated."""
@final
def __call__(
self,
common_options: CommonTableOptions,
progress: tp.Optional["Progress"] = None,
task_id: tp.Optional["TaskID"] = None
) -> None:
"""
Generate the tables as specified by this generator.
Args:
common_options: common options to use for the table(s)
"""
table_dir = common_options.table_base_dir / common_options.table_dir
if not table_dir.exists():
table_dir.mkdir(parents=True)
tables = self.generate()
if len(tables) > 1 and common_options.view:
common_options.view = cli_yn_choice(
f"Do you really want to view all {len(tables)} tables? "
f"If you answer 'no', the tables will still be generated.", "n"
)
if progress:
if task_id is None:
task_id = progress.add_task(
total=len(tables), description=f"Building {self.NAME}"
)
else:
progress.update(task_id, total=len(tables))
for table in tables:
if common_options.dry_run:
LOG.info(repr(table))
continue
if common_options.view:
table.show()
else:
table.save(
table_dir,
table_format=common_options.table_format,
wrap_table=common_options.wrap_table
)
if progress and task_id is not None:
progress.advance(task_id)
[docs]
class TableArtefact(Artefact, artefact_type="table", artefact_type_version=2):
"""
An artefact defining a :class:`~varats.tables.table.Table`.
Args:
name: name of this artefact
output_dir: output dir relative to config value
'artefacts/artefacts_dir'
table_generator_type: the
:attr:`type of table<varats.table.tables.TableGenerator>`
to use
kwargs: additional arguments that will be passed to the table class
"""
def __init__(
self, name: str, output_dir: Path, table_generator_type: str,
common_options: CommonTableOptions, table_config: TableConfig,
**kwargs: tp.Any
) -> None:
super().__init__(name, output_dir)
self.__table_generator_type = table_generator_type
self.__table_type_class = \
TableGenerator.get_class_for_table_generator_type(
self.__table_generator_type
)
self.__common_options = common_options
self.__common_options.table_base_dir = Artefact.base_output_dir()
self.__common_options.table_dir = output_dir
self.__table_config = table_config
self.__table_kwargs = kwargs
@property
def table_generator_type(self) -> str:
"""The type of table generator used to generate this artefact."""
return self.__table_generator_type
@property
def table_generator_class(self) -> tp.Type[TableGenerator]:
"""The class associated with :func:`table_generator_type`."""
return self.__table_type_class
@property
def common_options(self) -> CommonTableOptions:
"""Options that are available to all tables."""
return self.__common_options
@property
def table_config(self) -> TableConfig:
"""Options that influence the visual representation of a table."""
return self.__table_config
@property
def table_kwargs(self) -> tp.Any:
"""Additional arguments that will be passed to the table."""
return self.__table_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['table_generator'] = self.__table_generator_type
artefact_dict['table_config'] = self.__table_config.get_dict()
artefact_dict = {
**self.__common_options.get_dict(),
**convert_kwargs(
self.table_generator_class.OPTIONS,
self.__table_kwargs,
to_string=True
),
**artefact_dict
}
artefact_dict.pop("table_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
"""
table_generator_type = kwargs.pop('table_generator')
common_options = CommonTableOptions.from_kwargs(**kwargs)
table_config = TableConfig.from_kwargs(
common_options.view, **kwargs.pop("table_config", {})
)
return TableArtefact(
name, output_dir, table_generator_type, common_options,
table_config,
**convert_kwargs(
TableGenerator.get_class_for_table_generator_type(
table_generator_type
).OPTIONS,
kwargs,
to_string=False
)
)
[docs]
@staticmethod
def from_generator(
name: str, generator: TableGenerator, common_options: CommonTableOptions
) -> 'TableArtefact':
"""
Create a table artefact from a generator.
Args:
name: name for the artefact
generator: generator class to use for the artefact
common_options: common table options
Returns:
an instantiated table artefact
"""
return TableArtefact(
name, common_options.table_dir, generator.NAME, common_options,
generator.table_config, **generator.table_kwargs
)
[docs]
def generate_artefact(
self, progress: tp.Optional["Progress"] = None
) -> None:
"""Generate the specified table(s)."""
task_id = None
if progress:
task_id = progress.add_task(description=f"Building {self.name}")
generator_instance = self.table_generator_class(
self.table_config, **self.__table_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.table_generator_class(
self.table_config, **self.__table_kwargs
)
return [
ArtefactFileInfo(
table.table_file_name(self.common_options.table_format),
table.table_kwargs.get("case_study", None)
) for table in generator_instance.generate()
]