import enum
import io
import sys
from itertools import chain
from typing import IO, Any, List, Optional, Sequence, Union, cast
import typepy
from dataproperty import ColumnDataProperty, DataProperty, LineBreakHandling
from ...error import EmptyTableDataError
from ...style import Cell, ColSeparatorStyleFilterFunc, Style, StylerInterface, TextStyler
from .._common import HEADER_ROW
from .._table_writer import AbstractTableWriter
from ._interface import IndentationInterface, TextWriterInterface
@enum.unique
class RowType(enum.Enum):
OPENING = "opening"
HEADER_SEPARATOR = "header separator"
MIDDLE = "middle"
CLOSING = "closing"
[docs]
class TextTableWriter(AbstractTableWriter, TextWriterInterface):
"""
A base class for table writer with text formats.
.. figure:: ss/table_char.png
:scale: 60%
:alt: table_char
Character attributes that compose a table
.. py:attribute:: column_delimiter
:type: str
A column delimiter of a table.
.. py:attribute:: char_left_side_row
:type: str
A character of a left side of a row.
.. py:attribute:: char_right_side_row
:type: str
A character of a right side of a row.
.. py:attribute:: char_cross_point
:type: str
A character of the crossing point of column delimiter and row
delimiter.
.. py:attribute:: char_opening_row
:type: str
A character of the first line of a table.
.. py:attribute:: char_header_row_separator
:type: str
A character of a separator line of the header and
the body of the table.
.. py:attribute:: char_value_row_separator
:type: str
A character of a row separator line of the table.
.. py:attribute:: char_closing_row
:type: str
A character of the last line of a table.
.. py:attribute:: is_write_header_separator_row
:type: bool
Write a header separator line of the table if the value is |True|.
.. py:attribute:: is_write_value_separator_row
:type: bool
Write row separator line(s) of the table if the value is |True|.
.. py:attribute:: is_write_opening_row
:type: bool
Write an opening line of the table if the value is |True|.
.. py:attribute:: is_write_closing_row
:type: bool
Write a closing line of the table if the value is |True|.
.. py:attribute:: is_write_null_line_after_table
:type: bool
Write a blank line of after writing a table if the value is |True|.
.. py:attribute:: margin
:type: int
Margin size for each cells
"""
def __update_template(self) -> None:
self.__value_cell_margin_format = self.__make_margin_format(" ")
self.__opening_row_cell_format = self.__make_margin_format(self.char_opening_row)
self._header_row_separator_cell_format = self.__make_margin_format(
self.char_header_row_separator
)
self.__value_row_separator_cell_format = self.__make_margin_format(
self.char_value_row_separator
)
self.__closing_row_cell_format = self.__make_margin_format(self.char_closing_row)
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.stream = sys.stdout
self._set_chars("")
self.column_delimiter = "|"
self.char_opening_row = "-"
self.char_opening_row_cross_point = "-"
self.char_header_row_separator = "-"
self.char_header_row_cross_point = "-"
self.char_value_row_separator = "-"
self.char_closing_row = "-"
self.char_closing_row_cross_point = "-"
self._margin = kwargs.get("margin", 0)
self._dp_extractor.preprocessor.line_break_handling = LineBreakHandling.REPLACE
self.is_write_null_line_after_table = kwargs.get("is_write_null_line_after_table", False)
self._init_cross_point_maps()
self._col_separator_style_filters: List[ColSeparatorStyleFilterFunc] = []
if "theme" in kwargs:
self.set_theme(kwargs["theme"])
def __repr__(self) -> str:
return self.dumps()
@property
def margin(self) -> int:
return self._margin
@margin.setter
def margin(self, value: int) -> None:
if value < 0:
raise ValueError("margin value must be zero or greater")
if self._margin == value:
return
self._margin = value
self._clear_preprocess()
def _init_cross_point_maps(self) -> None:
self.__cross_point_maps = {
RowType.OPENING: self.char_opening_row_cross_point,
RowType.HEADER_SEPARATOR: self.char_header_row_cross_point,
RowType.MIDDLE: self.char_cross_point,
RowType.CLOSING: self.char_closing_row_cross_point,
}
self.__left_cross_point_maps = {
RowType.OPENING: self.char_top_left_cross_point,
RowType.HEADER_SEPARATOR: self.char_header_row_left_cross_point,
RowType.MIDDLE: self.char_left_cross_point,
RowType.CLOSING: self.char_bottom_left_cross_point,
}
self.__right_cross_point_maps = {
RowType.OPENING: self.char_top_right_cross_point,
RowType.HEADER_SEPARATOR: self.char_header_row_right_cross_point,
RowType.MIDDLE: self.char_right_cross_point,
RowType.CLOSING: self.char_bottom_right_cross_point,
}
[docs]
def add_col_separator_style_filter(self, style_filter: ColSeparatorStyleFilterFunc) -> None:
"""Add a style filter function for columns to the writer.
Args:
style_filter:
A function that called for each cell in the table to apply a style
to table cells.
The function will be required to implement the following Protocol:
.. code-block:: python
class ColSeparatorStyleFilterFunc(Protocol):
def __call__(
self, left_cell: Optional[Cell], right_cell: Optional[Cell], **kwargs: Any
) -> Optional[Style]:
...
If more than one style filter function is added to the writer,
it will be called from the last one added.
These style functions should return |None| when not needed to apply styles.
If all of the style functions returned |None|,
:py:attr:`~.default_style` will be applied.
You can pass keyword arguments to style filter functions via
:py:attr:`~.style_filter_kwargs`. In default, the attribute includes:
- ``writer``: the writer instance that the caller of a ``style_filter function``
"""
self._col_separator_style_filters.insert(0, style_filter)
self._clear_preprocess()
[docs]
def clear_theme(self) -> None:
"""Remove all of the style filters."""
super().clear_theme()
if not self._col_separator_style_filters:
return
self._col_separator_style_filters = []
self._clear_preprocess()
[docs]
def write_null_line(self) -> None:
"""
Write a null line to the |stream|.
"""
self._write_line()
[docs]
def write_table(self, **kwargs: Any) -> None:
"""
|write_table|.
.. note::
- |None| values are written as an empty string.
"""
try:
super().write_table(**kwargs)
except EmptyTableDataError:
raise
if self.is_write_null_line_after_table:
self.write_null_line()
[docs]
def dump(self, output: Union[str, IO], close_after_write: bool = True, **kwargs: Any) -> None:
"""Write data to the output with tabular format.
During the executing this method,
:py:attr:`~pytablewriter.writer._table_writer.AbstractTableWriter.enable_ansi_escape`
attribute will be temporarily set to |False|.
Args:
output:
The value must either an output stream or a path to an output file.
close_after_write:
Close the output after write.
Defaults to |True|.
"""
try:
output.write # type: ignore
self.stream = output
except AttributeError:
self.stream = open(output, "w", encoding="utf-8") # type: ignore
try:
stash = self.enable_ansi_escape
self.enable_ansi_escape = False
self.write_table(**kwargs)
finally:
if close_after_write:
self.stream.close() # type: ignore
self.stream = sys.stdout
self.enable_ansi_escape = stash
[docs]
def dumps(self, **kwargs: Any) -> str:
"""Get rendered tabular text from the table data.
Only available for text format table writers.
Args:
**kwargs:
Optional arguments that the writer takes.
Returns:
str: Rendered tabular text.
"""
old_stream = self.stream
try:
self.stream = io.StringIO()
self.write_table(**kwargs)
tabular_text = self.stream.getvalue()
finally:
self.stream = old_stream
return tabular_text
def _set_chars(self, c: str) -> None:
self.char_left_side_row = c
self.char_right_side_row = c
self.char_cross_point = c
self.char_left_cross_point = c
self.char_right_cross_point = c
self.char_top_left_cross_point = c
self.char_top_right_cross_point = c
self.char_bottom_left_cross_point = c
self.char_bottom_right_cross_point = c
self.char_opening_row = c
self.char_opening_row_cross_point = c
self.char_header_row_separator = c
self.char_header_row_cross_point = c
self.char_header_row_left_cross_point = c
self.char_header_row_right_cross_point = c
self.char_value_row_separator = c
self.char_closing_row = c
self.char_closing_row_cross_point = c
self._init_cross_point_maps()
def _create_styler(self, writer: AbstractTableWriter) -> StylerInterface:
return TextStyler(writer)
def _write_table_iter(self, **kwargs: Any) -> None:
super()._write_table_iter()
if self.is_write_null_line_after_table:
self.write_null_line()
def _write_table(self, **kwargs: Any) -> None:
self._preprocess()
self._write_opening_row()
try:
self._write_header()
self.__write_header_row_separator()
except ValueError:
pass
is_first_value_row = True
for row, (values, value_dp_list) in enumerate(
zip(self._table_value_matrix, self._table_value_dp_matrix)
):
try:
if is_first_value_row:
is_first_value_row = False
else:
if self.is_write_value_separator_row:
self._write_value_row_separator()
self._write_value_row(row, cast(List[str], values), value_dp_list)
except TypeError:
continue
self._write_closing_row()
def _get_opening_row_items(self) -> List[str]:
return self.__get_row_separator_items(self.__opening_row_cell_format, self.char_opening_row)
def _get_header_row_separator_items(self) -> List[str]:
return self.__get_row_separator_items(
self._header_row_separator_cell_format, self.char_header_row_separator
)
def _get_value_row_separator_items(self) -> List[str]:
return self.__get_row_separator_items(
self.__value_row_separator_cell_format, self.char_value_row_separator
)
def _get_closing_row_items(self) -> List[str]:
return self.__get_row_separator_items(self.__closing_row_cell_format, self.char_closing_row)
def __get_row_separator_items(self, margin_format: str, separator_char: str) -> List[str]:
return [
margin_format.format(separator_char * self._get_padding_len(col_dp))
for col_dp in self._column_dp_list
]
def _to_header_item(self, col_dp: ColumnDataProperty, value_dp: DataProperty) -> str:
return self.__value_cell_margin_format.format(super()._to_header_item(col_dp, value_dp))
def _apply_style_to_row_item(
self, row_idx: int, col_dp: ColumnDataProperty, value_dp: DataProperty, style: Style
) -> str:
return self.__value_cell_margin_format.format(
super()._apply_style_to_row_item(row_idx, col_dp, value_dp, style)
)
def _write_raw_string(self, unicode_text: str) -> None:
self.stream.write(unicode_text)
def _write_raw_line(self, unicode_text: str = "") -> None:
self._write_raw_string(unicode_text + "\n")
def _write(self, text: str) -> None:
self._write_raw_string(text)
def _write_line(self, text: str = "") -> None:
self._write_raw_line(text)
def _fetch_col_separator_style(
self, left_cell: Optional[Cell], right_cell: Optional[Cell], default_style: Style
) -> Style:
for style_filter in self._col_separator_style_filters:
style = style_filter(left_cell, right_cell, **self.style_filter_kwargs)
if style:
return style
return default_style
def __to_column_delimiter(
self,
row: int,
left_col_dp: Optional[ColumnDataProperty],
right_col_dp: Optional[ColumnDataProperty],
col_delimiter: str,
) -> str:
left_cell = None
if left_col_dp:
left_cell = Cell(
row=row,
col=left_col_dp.column_index,
value=col_delimiter,
default_style=self._get_col_style(left_col_dp.column_index),
)
right_cell = None
if right_col_dp:
right_cell = Cell(
row=row,
col=right_col_dp.column_index,
value=col_delimiter,
default_style=self._get_col_style(right_col_dp.column_index),
)
style = self._fetch_col_separator_style(
left_cell=left_cell,
right_cell=right_cell,
default_style=self.default_style,
)
return self._styler.apply_terminal_style(col_delimiter, style=style)
def _write_row(self, row: int, values: Sequence[str]) -> None:
if typepy.is_empty_sequence(values):
return
col_delimiters = (
[
self.__to_column_delimiter(
row,
None,
self._column_dp_list[0],
self.char_left_side_row,
)
]
+ [
self.__to_column_delimiter(
row,
self._column_dp_list[col_idx],
self._column_dp_list[col_idx + 1],
self.column_delimiter,
)
for col_idx in range(len(self._column_dp_list) - 1)
]
+ [
self.__to_column_delimiter(
row,
self._column_dp_list[-1],
None,
self.char_right_side_row,
)
]
)
row_items = [""] * (len(col_delimiters) + len(values))
row_items[::2] = col_delimiters
row_items[1::2] = list(values)
self._write_line("".join(chain.from_iterable(row_items)))
def _write_header(self) -> None:
if not self.is_write_header:
return
if typepy.is_empty_sequence(self._table_headers):
raise ValueError("header is empty")
self._write_row(HEADER_ROW, self._table_headers)
def _write_value_row(
self, row: int, values: Sequence[str], value_dp_list: Sequence[DataProperty]
) -> None:
self._write_row(row, values)
def __write_separator_row(
self, values: Sequence[str], row_type: RowType = RowType.MIDDLE
) -> None:
if typepy.is_empty_sequence(values):
return
cross_point = self.__cross_point_maps[row_type]
left_cross_point = self.__left_cross_point_maps[row_type]
right_cross_point = self.__right_cross_point_maps[row_type]
left_cross_point = left_cross_point if left_cross_point else cross_point
right_cross_point = right_cross_point if right_cross_point else cross_point
if typepy.is_null_string(self.char_left_side_row):
left_cross_point = ""
if typepy.is_null_string(self.char_right_side_row):
right_cross_point = ""
self._write_line(left_cross_point + cross_point.join(values) + right_cross_point)
def _write_opening_row(self) -> None:
if not self.is_write_opening_row:
return
self.__write_separator_row(self._get_opening_row_items(), row_type=RowType.OPENING)
def __write_header_row_separator(self) -> None:
if any([not self.is_write_header, not self.is_write_header_separator_row]):
return
self.__write_separator_row(
self._get_header_row_separator_items(), row_type=RowType.HEADER_SEPARATOR
)
def _write_value_row_separator(self) -> None:
"""
Write row separator of the table which matched to the table type
regardless of the value of the
:py:attr:`.is_write_value_separator_row`.
"""
self.__write_separator_row(self._get_value_row_separator_items())
def _write_closing_row(self) -> None:
if not self.is_write_closing_row:
return
self.__write_separator_row(self._get_closing_row_items(), row_type=RowType.CLOSING)
def __make_margin_format(self, margin_char: str) -> str:
margin_str = margin_char * self._margin
return margin_str + "{:s}" + margin_str
def _preprocess_table_property(self) -> None:
super()._preprocess_table_property()
self.__update_template()
self._init_cross_point_maps()
[docs]
class IndentationTextTableWriter(TextTableWriter, IndentationInterface):
"""A base class for table writer with indentation text formats.
Args:
indent_level (int): Indentation level. Defaults to ``0``.
.. py:attribute:: indent_string
Indentation string for each level.
"""
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.set_indent_level(kwargs.get("indent_level", 0))
self.indent_string = kwargs.get("indent_string", "")
[docs]
def set_indent_level(self, indent_level: int) -> None:
"""Set the indentation level.
Args:
indent_level (int): New indentation level.
"""
self._indent_level = indent_level
[docs]
def inc_indent_level(self) -> None:
"""Increment the indentation level."""
self._indent_level += 1
[docs]
def dec_indent_level(self) -> None:
"""Decrement the indentation level."""
self._indent_level -= 1
[docs]
def write_table(self, **kwargs: Any) -> None:
"""
|write_table|.
Args:
indent (Optional[int]):
Indent level of an output.
Interpretation of indent level value differ format to format.
Some writer classes may ignore this value.
.. note::
- |None| values are written as an empty string.
"""
indent = kwargs.pop("indent", None)
if indent is not None:
self._logger.logger.debug(f"indent: {indent}")
self.set_indent_level(int(indent))
try:
super().write_table(**kwargs)
except EmptyTableDataError:
self._logger.logger.debug("no tabular data found")
return
def _get_indent_string(self) -> str:
return self.indent_string * self._indent_level
def _write(self, text: str) -> None:
self._write_raw_string(self._get_indent_string() + text)
def _write_line(self, text: str = "") -> None:
if typepy.is_not_null_string(text):
self._write_raw_line(self._get_indent_string() + text)
else:
self._write_raw_line("")