"""Mixin for target-related functionality for the Reporter class."""
from __future__ import annotations
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING
from rich.console import Console
from rich.table import Table
from rich.text import Text
from simplebench.enums import Format, Section
from simplebench.exceptions import SimpleBenchTypeError, SimpleBenchValueError
from simplebench.reporters.protocols import ReporterCallback
from simplebench.reporters.reporter.exceptions import _ReporterErrorTag
from simplebench.reporters.reporter.protocols import ReporterProtocol
from simplebench.validators import validate_filename, validate_string, validate_type
if TYPE_CHECKING:
from simplebench.case import Case
from simplebench.session import Session
class _ReporterTargetMixin:
"""Mixin for target-related functionality for the Reporter class."""
def target_filesystem(self: ReporterProtocol, *,
path: Path | None,
subdir: str,
filename: str,
output: str | bytes | Text | Table,
unique: bool,
append: bool,) -> None:
"""Helper method to output report data to the filesystem.
path, subdir, and filename are combined to form the full path to the output file.
If unique is True, the filename will be made unique by prepending a counter
starting from 001 to the filename and counting up until a unique filename is found.
E.g. 001_filename.txt, 002_filename.txt, etc.
If append is True, the output will be appended to the file if it already exists.
Otherwise, an exception will be raised if the file already exists. Note that
append mode is not compatible with unique mode.
The type signature for path is Path | None because the overall report() method
accepts path as Optional[Path] because it is not always required. However,
this method should only be called when a valid Path is provided and will
raise an exception if it is not a Path instance.
:param path: The path to the directory where output should be saved.
:type path: Path | None
:param subdir: The subdirectory within the path to save the file to.
:type subdir: str
:param filename: The filename to save the output as.
:type filename: str
:param output: The report data to write to the file.
:type output: str | bytes | Text | Table
:param unique: If True, ensure the filename is unique by prepending a counter as needed.
:type unique: bool
:param append: If True, append to the file if it already exists. Otherwise, raise an error.
:type append: bool
:raises SimpleBenchTypeError: If path is not a Path instance,
or if subdir or filename are not strings.
:raises SimpleBenchValueError: If both append and unique are True. Or if the output file
already exists and neither append nor unique options were specified.
"""
path = validate_type(
path, Path, 'path',
_ReporterErrorTag.TARGET_FILESYSTEM_INVALID_PATH_ARG_TYPE)
subdir = validate_string(
subdir, 'subdir',
_ReporterErrorTag.TARGET_FILESYSTEM_INVALID_SUBDIR_ARG_TYPE,
_ReporterErrorTag.TARGET_FILESYSTEM_INVALID_SUBDIR_ARG_VALUE,
strip=False, allow_empty=True, allow_blank=False, alphanumeric_only=True)
filename = validate_filename(filename)
append = validate_type(
append, bool, 'append',
_ReporterErrorTag.TARGET_FILESYSTEM_INVALID_APPEND_ARG_TYPE)
unique = validate_type(
unique, bool, 'unique',
error_tag=_ReporterErrorTag.TARGET_FILESYSTEM_INVALID_UNIQUE_ARG_TYPE)
if not isinstance(output, (str, bytes, Text, Table)):
raise SimpleBenchTypeError(
"output must be of type str, bytes, Text, or Table",
tag=_ReporterErrorTag.TARGET_FILESYSTEM_INVALID_OUTPUT_ARG_TYPE)
if append == unique:
raise SimpleBenchValueError(
"one, and only one, of append or unique must be True when writing to filesystem",
tag=_ReporterErrorTag.TARGET_FILESYSTEM_APPEND_UNIQUE_INCOMPATIBLE_ARGS)
if unique:
counter = 1
while (path / subdir / f"{counter:03d}_{filename}").exists():
counter += 1
filename = f"{counter:03d}_{filename}"
if isinstance(output, (Text, Table)):
output = self.rich_text_to_plain_text(output)
output_path = path / subdir / filename
output_path.parent.mkdir(parents=True, exist_ok=True)
mode = 'wb' if isinstance(output, bytes) else 'w'
if append:
mode = 'ab' if isinstance(output, bytes) else 'a'
with output_path.open(mode=mode) as f:
f.write(output)
def target_callback(self: ReporterProtocol,
callback: ReporterCallback | None,
case: Case,
section: Section,
output_format: Format,
output: str | bytes | Text | Table) -> None:
"""Helper method to send report data to a callback function.
:param callback: The callback function to send the output to.
:type callback: ReporterCallback | None
:param case: The Case instance representing the benchmarked code.
:type case: Case
:param section: The Section of the report.
:type section: Section
:param output_format: The Format of the report.
:type output_format: Format
:param output: The report data to send to the callback.
:type output: str | bytes | Text | Table
"""
# Rich text is not generally suitable for callback output, convert to plain text
if isinstance(output, (Text, Table)):
output = self.rich_text_to_plain_text(output)
output_format = Format.PLAIN_TEXT
# The logic behind this is that only test cases that have a callback defined
# should invoke the callback, otherwise it would be unexpected behavior.
#
# This allows reporters to call this method without needing to check
# if a callback is defined each time. It allows end users to define
# callbacks only when they want to handle output via callback without
# having to define a no-op callback for all other cases.
#
# It is not perfectly symmetrical with target_console, which always outputs to console,
# or target_filesystem, which always writes to the filesystem,
# but callbacks are more specialized in nature.
#
# It is different, but intentional.
if callback is not None:
callback(case=case, section=section, output_format=output_format, output=output)
def target_console(self: ReporterProtocol, session: Session | None, output: str | bytes | Text | Table) -> None:
"""Helper method to output report data to the console.
It uses the Rich Console instance from the Session if provided, otherwise
it creates a new Console instance.
It can accept output as a string, Rich Text, or Rich Table.
:param session: The Session instance containing the console.
:type session: Session | None
:param output: The report data to print to the console.
:type output: str | bytes | Text | Table
"""
console = session.console if session is not None else Console()
console.print(output)
def rich_text_to_plain_text(self: ReporterProtocol, rich_text: Text | Table) -> str:
"""Convert Rich Text or Table to plain text by stripping formatting.
Applies a virtual console width to ensure proper line wrapping. The console
width simulates how the text would appear in a terminal of the specified width.
As rich text is normally mainly used for console output, this method
provides a way to convert it to plain text while preserving the intended
layout as much as possible for non-console output targets.
:param rich_text: The Rich Text or Table instance to convert.
:type rich_text: Text | Table
:return: The plain text representation of the Rich Text.
:rtype: str
"""
if not isinstance(rich_text, (Text, Table)):
raise SimpleBenchTypeError(
f'rich_text argument is of invalid type: {type(rich_text)}. '
f'Must be rich.Text or rich.Table.',
tag=_ReporterErrorTag.RICH_TEXT_TO_PLAIN_TEXT_INVALID_RICH_TEXT_ARG_TYPE)
output_io = StringIO() # just a string buffer to capture console output
console = Console(file=output_io, width=None, record=True)
console.print(rich_text)
text_output = console.export_text(styles=False)
output_io.close()
return text_output