Source code for cloup.formatting._formatter
import dataclasses as dc
import inspect
import shutil
import textwrap
from itertools import chain
from typing import (
Any, Callable, Dict, Iterable, Iterator, Optional, Sequence, TYPE_CHECKING,
Tuple, Union,
)
from cloup._util import click_version_ge_8_1
from cloup.formatting._util import unstyled_len
if TYPE_CHECKING:
from .sep import RowSepPolicy, SepGenerator
import click
from click.formatting import wrap_text
from cloup._util import (
check_positive_int, identity, indent_lines, make_repr,
pick_non_missing,
)
from ..typing import MISSING, Possibly
from cloup.styling import HelpTheme, IStyle
Definition = Tuple[str, Union[str, Callable[[int], str]]]
[docs]
@dc.dataclass()
class HelpSection:
"""A container for a help section data."""
heading: str
"""Help section title."""
definitions: Sequence[Definition]
"""Rows with 2 columns each. The 2nd element of each row can also be a function
taking an integer (the available width for the 2nd column) and returning a string."""
help: Optional[str] = None
"""(Optional) long description of the section."""
constraint: Optional[str] = None
"""(Optional) option group constraint description."""
# noinspection PyMethodMayBeStatic
[docs]
class HelpFormatter(click.HelpFormatter):
"""
A custom help formatter. Features include:
- more attributes for controlling the output of the formatter
- a ``col1_width`` parameter in :meth:`write_dl` that allows Cloup to align
multiple definition lists without resorting to hacks
- a "linear layout" for definition lists that kicks in when the available
terminal width is not enough for the standard 2-column layout
(see argument ``col2_min_width``)
- the first column width, when not explicitly given in ``write_dl`` is
computed excluding the rows that exceed ``col1_max_width``
(called ``col_max`` in ``write_dl`` for compatibility with Click).
.. versionchanged:: 0.9.0
the ``row_sep`` parameter now:
- is set to ``None`` by default and ``row_sep=""`` corresponds to an
empty line between rows
- must not ends with ``\\n``; the formatter writes a newline just after
it (when it's not ``None``), so a newline at the end is always enforced
- accepts instances of :class:`~cloup.formatting.sep.SepGenerator` and
:class:`~cloup.formatting.sep.RowSepPolicy`.
.. versionadded:: 0.8.0
:param indent_increment:
width of each indentation increment.
:param width:
content line width, excluding the newline character; by default it's
initialized to ``min(terminal_width - 1, max_width)`` where
``max_width`` is another argument.
:param max_width:
maximum content line width (equivalent to ``Context.max_content_width``).
Used to compute ``width`` when it is not provided, ignored otherwise.
:param col1_max_width:
the maximum width of the first column of a definition list; as in Click,
if the text of a row exceeds this threshold, the 2nd column is printed
on a new line.
:param col2_min_width:
the minimum width for the second column of a definition list; if the
available space is less than this value, the formatter switches from the
standard 2-column layout to the "linear layout" (that this decision
is taken for each definition list). If you want to always use the linear
layout, you can set this argument to a very high number (or ``math.inf``).
If you never want it (not recommended), you can set this argument to zero.
:param col_spacing:
the number of spaces between the column boundaries of a definition list.
:param row_sep:
an "extra" separator to insert between the rows of a definition list (in
addition to the normal newline between definitions). If you want an empty
line between rows, pass ``row_sep=""``.
Read :ref:`Row separators <row-separators>` for more.
:param theme:
an :class:`~cloup.HelpTheme` instance specifying how to style the various
elements of the help page.
"""
def __init__(
self, indent_increment: int = 2,
width: Optional[int] = None,
max_width: Optional[int] = None,
col1_max_width: int = 30,
col2_min_width: int = 35,
col_spacing: int = 2,
row_sep: Union[None, str, 'SepGenerator', 'RowSepPolicy'] = None,
theme: HelpTheme = HelpTheme(),
):
check_positive_int(col1_max_width, 'col1_max_width')
check_positive_int(col_spacing, 'col_spacing')
if isinstance(row_sep, str) and row_sep.endswith('\n'):
raise ValueError(
"since v0.9, row_sep must not end with '\\n'. The formatter writes "
"a '\\n' after it; no other newline is allowed.\n"
"If you want an empty line between rows, set row_sep=''.")
max_width = max_width or 80
# We subtract 1 to the terminal width to leave space for the new line character.
# Otherwise, when we write a line that is long exactly terminal_size (without \n)
# the \n is printed on a new terminal line, leading to a useless empty line.
width = (
width
or click.formatting.FORCED_WIDTH
or min(max_width, shutil.get_terminal_size((80, 100)).columns - 1)
)
super().__init__(
width=width, max_width=max_width, indent_increment=indent_increment
)
self.width: int = width
self.col1_max_width = col1_max_width
self.col2_min_width = col2_min_width
self.col_spacing = col_spacing
self.theme = theme
self.row_sep = row_sep
[docs]
@staticmethod
def settings(
*, width: Possibly[Optional[int]] = MISSING,
max_width: Possibly[Optional[int]] = MISSING,
indent_increment: Possibly[int] = MISSING,
col1_max_width: Possibly[int] = MISSING,
col2_min_width: Possibly[int] = MISSING,
col_spacing: Possibly[int] = MISSING,
row_sep: Possibly[Union[None, str, 'SepGenerator', 'RowSepPolicy']] = MISSING,
theme: Possibly[HelpTheme] = MISSING,
) -> Dict[str, Any]:
"""A utility method for creating a ``formatter_settings`` dictionary to
pass as context settings or command attribute. This method exists for
one only reason: it enables auto-complete for formatter options, thus
improving the developer experience.
Parameters are described in :class:`HelpFormatter`.
"""
return pick_non_missing(locals())
@property
def available_width(self) -> int:
return self.width - self.current_indent
[docs]
def write_usage(
self, prog: str, args: str = "", prefix: Optional[str] = None
) -> None:
prefix = "Usage:" if prefix is None else prefix
prefix = self.theme.heading(prefix) + " "
prog = self.theme.invoked_command(prog)
super().write_usage(prog, args, prefix)
[docs]
def write_aliases(self, aliases: Sequence[str]) -> None:
self.write_heading("Aliases", newline=False)
alias_list = ", ".join(self.theme.col1(alias) for alias in aliases)
self.write(f" {alias_list}\n")
[docs]
def write_command_help_text(self, cmd: click.Command) -> None:
help_text = cmd.help or ""
if help_text and click_version_ge_8_1:
help_text = inspect.cleandoc(help_text).partition("\f")[0]
if cmd.deprecated:
# Use the same label as Click:
# https://github.com/pallets/click/blob/b0538df/src/click/core.py#L1331
help_text = "(Deprecated) " + help_text
if help_text:
self.write_paragraph()
with self.indentation():
self.write_text(help_text, style=self.theme.command_help)
[docs]
def write_heading(self, heading: str, newline: bool = True) -> None:
if self.current_indent:
self.write(" " * self.current_indent)
self.write(self.theme.heading(heading + ":"))
if newline:
self.write('\n')
[docs]
def write_many_sections(
self, sections: Sequence[HelpSection],
aligned: bool = True,
) -> None:
if aligned:
return self.write_aligned_sections(sections)
for s in sections:
self.write_section(s)
[docs]
def write_aligned_sections(self, sections: Sequence[HelpSection]) -> None:
"""Write multiple aligned definition lists."""
all_rows = chain.from_iterable(dl.definitions for dl in sections)
col1_width = self.compute_col1_width(all_rows, self.col1_max_width)
for s in sections:
self.write_section(s, col1_width=col1_width)
[docs]
def write_section(self, s: HelpSection, col1_width: Optional[int] = None) -> None:
theme = self.theme
self.write("\n")
self.write_heading(s.heading, newline=not s.constraint)
if s.constraint:
constraint_text = f'[{s.constraint}]'
available_width = self.available_width - len(s.heading) - len(': ')
if len(constraint_text) <= available_width:
self.write(" ", theme.constraint(constraint_text), "\n")
else:
self.write("\n")
with self.indentation():
self.write_text(constraint_text, theme.constraint)
with self.indentation():
if s.help:
self.write_text(s.help, theme.section_help)
self.write_dl(s.definitions, col1_width=col1_width)
[docs]
def write_text(self, text: str, style: IStyle = identity) -> None:
wrapped = wrap_text(
text, self.width - self.current_indent, preserve_paragraphs=True)
if style is identity:
wrapped_text = textwrap.indent(wrapped, prefix=' ' * self.current_indent)
else:
styled_lines = map(style, wrapped.splitlines())
lines = indent_lines(styled_lines, width=self.current_indent)
wrapped_text = "\n".join(lines)
self.write(wrapped_text, "\n")
[docs]
def compute_col1_width(self, rows: Iterable[Definition], max_width: int) -> int:
col1_lengths = (unstyled_len(r[0]) for r in rows)
lengths_under_limit = (length for length in col1_lengths if length <= max_width)
return max(lengths_under_limit, default=0)
[docs]
def write_dl(
self, rows: Sequence[Definition],
col_max: Optional[int] = None, # default changed to None wrt parent class
col_spacing: Optional[int] = None, # default changed to None wrt parent class
col1_width: Optional[int] = None,
) -> None:
"""Write a definition list into the buffer. This is how options
and commands are usually formatted.
If there's enough space, definition lists are rendered as a 2-column
pseudo-table: if the first column text of a row doesn't fit in the
provided/computed ``col1_width``, the 2nd column is printed on the
following line.
If the available space for the 2nd column is below ``self.col2_min_width``,
the 2nd "column" is always printed below the 1st, indented with a minimum
of 3 spaces (or one ``indent_increment`` if that's greater than 3).
:param rows:
a list of two item tuples for the terms and values.
:param col_max:
the maximum width for the 1st column of a definition list; this
argument is here to not break compatibility with Click; if provided,
it overrides the attribute ``self.col1_max_width``.
:param col_spacing:
number of spaces between the first and second column;
this argument is here to not break compatibility with Click;
if provided, it overrides ``self.col_spacing``.
:param col1_width:
the width to use for the first column; if not provided, it's
computed as the length of the longest string under ``self.col1_max_width``;
useful when you need to align multiple definition lists.
"""
# |<----------------------- width ------------------------>|
# | |<---------- available_width ---------->|
# | current_indent | col1_width | col_spacing | col2_width |
col1_max_width = min(
col_max or self.col1_max_width,
self.available_width,
)
col1_width = min(
col1_width or self.compute_col1_width(rows, col1_max_width),
col1_max_width,
)
col_spacing = col_spacing or self.col_spacing
col2_width = self.available_width - col1_width - col_spacing
if col2_width < self.col2_min_width:
self.write_linear_dl(rows)
else:
self.write_tabular_dl(rows, col1_width, col_spacing, col2_width)
def _get_row_sep_for(
self, text_rows: Sequence[Sequence[str]],
col_widths: Sequence[int],
col_spacing: int,
) -> Optional[str]:
if self.row_sep is None or isinstance(self.row_sep, str):
return self.row_sep
from .sep import RowSepPolicy
if isinstance(self.row_sep, RowSepPolicy):
return self.row_sep(text_rows, col_widths, col_spacing)
elif callable(self.row_sep): # RowSepPolicy is callable; keep this for last
return self.row_sep(self.available_width)
else:
raise TypeError('row_sep')
[docs]
def write_tabular_dl(
self, rows: Sequence[Definition],
col1_width: int, col_spacing: int, col2_width: int,
) -> None:
"""Format a definition list as a 2-column "pseudo-table". If the first
column of a row exceeds ``col1_width``, the 2nd column is written on
the subsequent line. This is the standard way of formatting definition
lists and it's the default if there's enough space."""
col1_plus_spacing = col1_width + col_spacing
col2_indentation = " " * (
self.current_indent + max(self.indent_increment, col1_plus_spacing)
)
indentation = " " * self.current_indent
# Note: iter_defs() resolves eventual callables in row[1]
text_rows = list(iter_defs(rows, col2_width))
row_sep = self._get_row_sep_for(text_rows, (col1_width, col2_width), col_spacing)
col1_styler, col2_styler = self.theme.col1, self.theme.col2
def write_row(row: Tuple[str, str]) -> None:
first, second = row
self.write(indentation, col1_styler(first))
if not second:
self.write("\n")
else:
first_display_length = unstyled_len(first)
if first_display_length <= col1_width:
spaces_to_col2 = col1_plus_spacing - first_display_length
self.write(" " * spaces_to_col2)
else:
self.write("\n", col2_indentation)
if len(second) <= col2_width:
self.write(col2_styler(second), "\n")
else:
wrapped_text = wrap_text(second, col2_width, preserve_paragraphs=True)
lines = [col2_styler(line) for line in wrapped_text.splitlines()]
self.write(lines[0], "\n")
for line in lines[1:]:
self.write(col2_indentation, line, "\n")
write_row(text_rows[0])
for row in text_rows[1:]:
if row_sep is not None:
self.write(indentation, row_sep, "\n")
write_row(row)
[docs]
def write_linear_dl(self, dl: Sequence[Definition]) -> None:
"""Format a definition list as a "linear list". This is the default when
the available width for the definitions (2nd column) is below
``self.col2_min_width``."""
help_extra_indent = max(3, self.indent_increment)
help_total_indent = self.current_indent + help_extra_indent
help_max_width = self.width - help_total_indent
current_indentation = " " * self.current_indent
col1_styler = self.theme.col1
col2_styler = self.theme.col2
for names, help in iter_defs(dl, help_max_width):
self.write(current_indentation + col1_styler(names) + '\n')
if help:
self.current_indent += help_extra_indent
self.write_text(help, col2_styler)
self.current_indent -= help_extra_indent
self.write("\n")
self.buffer.pop() # pop last newline
def __repr__(self) -> str:
return make_repr(
self, width=self.width, indent_increment=self.indent_increment,
col1_max_width=self.col1_max_width, col_spacing=self.col_spacing
)
def iter_defs(rows: Iterable[Definition], col2_width: int) -> Iterator[Tuple[str, str]]:
for row in rows:
if len(row) == 1:
yield row[0], ''
elif len(row) == 2:
second = row[1](col2_width) if callable(row[1]) else row[1]
yield row[0], second
else:
raise ValueError(f'invalid row length: {len(row)}')