"""
This module provides classes and methods for beautifully formatted output to stdout.
This includes printing tables and grids, formatting dates and eliding strings.
"""
from __future__ import annotations
import enum
from datetime import datetime
from typing import Sequence, Any, Iterator
import click
from .utils import get_term_width
# ==== text adjustment helpers =========================================================
[docs]class Align(enum.Enum):
"""Text alignment in column"""
[docs]class Elide(enum.Enum):
"""Elide directives"""
[docs]class Prefix(enum.Enum):
"""Prefix for command line output"""
[docs]def elide(
text: str, width: int, placeholder: str = "...", elide: Elide = Elide.Trailing
) -> str:
"""
Elides a string to fit into the given width.
:param text: Text to truncate.
:param width: Target width.
:param placeholder: Placeholder string to indicate truncated text.
:param elide: Which part to truncate.
:returns: Truncated text.
"""
if len(text) <= width:
return text
available = width - len(placeholder)
if elide is Elide.Trailing:
return text[:available] + placeholder
elif elide is Elide.Leading:
return placeholder + text[-available:]
else:
half_available = available // 2
return text[:half_available] + placeholder + text[-half_available:]
[docs]def adjust(text: str, width: int, align: Align = Align.Left) -> str:
"""
Pads a string with spaces up the desired width. Preserves ANSI color codes without
counting them towards the width.
This function is similar to ``str.ljust`` and ``str.rjust``.
:param text: Initial string.
:param width: Target width. If smaller than the given text, nothing is done.
:param align: Side to align the padded string: to the left or to the right.
"""
needed = width - len(click.unstyle(text))
if needed > 0:
if align == Align.Left:
return text + " " * needed
else:
return " " * needed + text
else:
return text
# ==== printing structured data to console =============================================
[docs]class Field:
"""Base class to represent a field in a table."""
@property
[docs] def display_width(self) -> int:
"""
The requested total width of the content in characters when not wrapped or
shortened in any way.
"""
raise NotImplementedError()
[docs]class TextField(Field):
"""
A text field for a table.
:param text: Text to represent.
:param align: Text alignment: right or left.
:param wraps: Whether to wrap the text instead of truncating it to fit into a
requested width.
:elide: Truncation strategy: trailing, center or leading.
:param style: Styling passed on to :meth:`click.style` when styling the text.
"""
def __init__(
self,
text: str,
align: Align = Align.Left,
wraps: bool = False,
elide: Elide = Elide.Trailing,
**style,
) -> None:
self.text = text
self.align = align
self.wraps = wraps
self.elide = elide
self.style = style
@property
[docs] def display_width(self) -> int:
return len(self.text)
[docs] def format(self, width: int) -> list[str]:
import textwrap
if self.wraps:
lines = textwrap.wrap(self.text, width=width)
else:
lines = [elide(self.text, width, elide=self.elide)]
# apply style first, adjust later
if self.style:
lines = [click.style(line, **self.style) for line in lines]
return [adjust(line, width, self.align) for line in lines]
def __repr__(self):
return f"<{self.__class__.__name__}('{self.text}')>"
[docs]class DateField(Field):
"""
A datetime field for a table. The formatting of the datetime will be adjusted
depending on the available width. Does not currently support localisation.
:param dt: Datetime to represent.
:param style: Styling passed on to :meth:`click.style` when styling the text.
"""
def __init__(self, dt: datetime, **style) -> None:
self.dt = dt
self.style = style
@property
[docs] def display_width(self) -> int:
return 20
def __repr__(self):
return f"<{self.__class__.__name__}('{self.format(17)}')>"
[docs]class Column:
"""
A table column.
:param title: Column title.
:param fields: Fields in the table. Any sequence of objects can be given and will be
converted to :class:`Field` instances as appropriate.
:param align: How to align text inside the column. Will only be used for
:class:`TextField`.
:param wraps: Whether to wrap fields to fit into the column width instead of
truncating them. Will only be used for :class:`TextField`.
:param elide: How to elide text which is too wide for a column. Will only be used
for :class:`TextField`.
"""
def __init__(
self,
title: str | None,
fields: Sequence = (),
align: Align = Align.Left,
wraps: bool = False,
elide: Elide = Elide.Trailing,
) -> None:
self.title = TextField(title, align=align, bold=True) if title else None
self.align = align
self.wraps = wraps
self.elide = elide
self.fields = []
for field in fields:
self.fields.append(self._to_field(field))
@property
[docs] def display_width(self) -> int:
if self.title:
all_fields = self.fields + [self.title]
else:
all_fields = self.fields
return max(field.display_width for field in all_fields)
@property
[docs] def has_title(self):
return self.title is not None
[docs] def append(self, field: Any) -> None:
self.fields.append(self._to_field(field))
[docs] def insert(self, index: int, field: Any) -> None:
self.fields.insert(index, self._to_field(field))
def __getitem__(self, item: int) -> Field:
return self.fields[item]
def __setitem__(self, key: int, value: Any) -> None:
self.fields[key] = self._to_field(value)
def __iter__(self) -> Iterator[Field]:
return iter(self.fields)
def __len__(self) -> int:
return len(self.fields)
def _to_field(self, field: Any) -> Field:
from datetime import datetime
if isinstance(field, Field):
return field
elif isinstance(field, datetime):
return DateField(field)
else:
return TextField(
str(field), align=self.align, wraps=self.wraps, elide=self.elide
)
def __repr__(self):
title = self.title.text if self.title else "untitled"
return f"<{self.__class__.__name__}(title='{title}')>"
[docs]class Table:
"""
A table which can be printed to stdout.
:param columns: Table columns. Can be a list of :class:`Column` instances or table
titles.
:param padding: Padding between columns.
"""
def __init__(self, columns: list[Column | str], padding: int = 2) -> None:
self.columns = []
self.padding = padding
for col in columns:
if isinstance(col, Column):
self.columns.append(col)
else:
self.columns.append(Column(col))
@property
[docs] def ncols(self) -> int:
"""The number of columns"""
return len(self.columns)
@property
[docs] def nrows(self) -> int:
"""The number of rows"""
return max(len(col) for col in self.columns)
[docs] def append(self, row: Sequence) -> None:
"""
Appends a new row to the table.
:param row: List of fields to append to each column. Length must match the
number of columns.
"""
if len(row) != self.ncols:
raise ValueError(f"Got {len(row)} fields but have {self.ncols} columns")
for i, col in enumerate(self.columns):
col.append(row[i])
[docs] def rows(self) -> list[list[Field]]:
"""
Returns a list of rows in the table. Each row is a list of fields.
"""
return [[col[i] for col in self.columns] for i in range(len(self))]
def __len__(self) -> int:
return self.nrows
[docs] def echo(self):
"""Prints the table to the terminal."""
for line in self.format_lines():
click.echo(line)
[docs]class Grid:
"""
A grid of fields which can be printed to stdout.
:param fields: A sequence of fields (strings, datetimes, any objects with a string
representation).
:param padding: Padding between fields.
:param align: Alignment of strings in the grid.
"""
def __init__(
self, fields: Sequence = (), padding: int = 2, align: Align = Align.Left
):
self.fields = []
self.padding = padding
self.align = align
for field in fields:
self.fields.append(self._to_field(field))
[docs] def append(self, field: Any) -> None:
"""Appends a field to the grid."""
self.fields.append(self._to_field(field))
def __iter__(self) -> Iterator:
return iter(self.fields)
def __len__(self) -> int:
return len(self.fields)
def _to_field(self, field: Any) -> Field:
from datetime import datetime
if isinstance(field, Field):
return field
elif isinstance(field, datetime):
return DateField(field)
else:
return TextField(str(field), align=self.align)
[docs] def echo(self):
"""Prints the grid to the terminal."""
for line in self.format_lines():
click.echo(line)
# ==== printing messages to console ====================================================
[docs]def echo(message: str, nl: bool = True, prefix: Prefix = Prefix.NONE) -> None:
"""
Print a message to stdout.
:param message: The string to output.
:param nl: Whether to end with a new line.
:param prefix: Any prefix to output before the message,
"""
if prefix is Prefix.Ok:
pre = click.style("✓", fg="green") + " "
elif prefix is Prefix.Warn:
pre = click.style("!", fg="red") + " "
elif prefix is Prefix.Info:
pre = "- "
else:
pre = ""
click.echo(f"{pre}{message}", nl=nl)
[docs]def info(message: str, nl: bool = True) -> None:
"""
Print an info message to stdout. Will be prefixed with a dash.
:param message: The string to output.
:param nl: Whether to end with a new line.
"""
echo(message, nl=nl, prefix=Prefix.Info)
[docs]def warn(message: str, nl: bool = True) -> None:
"""
Print a warning to stdout. Will be prefixed with an exclamation mark.
:param message: The string to output.
:param nl: Whether to end with a new line.
"""
echo(message, nl=nl, prefix=Prefix.Warn)
[docs]def ok(message: str, nl: bool = True) -> None:
"""
Print a confirmation to stdout. Will be prefixed with a checkmark.
:param message: The string to output.
:param nl: Whether to end with a new line.
"""
echo(message, nl=nl, prefix=Prefix.Ok)