# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Options to pick a table's format, sort its rows and select its columns.
`--table-format`, `--sort-by` and `--columns` publish their choice on the
context, where the renderers of {mod}`click_extra.table` read it.
"""
from __future__ import annotations
from functools import partial
from gettext import gettext as _
import click
# The commands module imports this one: its `Context` is read through the module
# object, once the callbacks run.
from . import commands, context
from .parameters import ExtraOption
from .table import (
DEFAULT_FORMAT,
TableFormat,
_defs_sort_key,
_normalize_column_def,
_row_sort_key,
print_table,
render_table,
)
from .types import EnumChoice, MultiChoice
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from typing import Any
from .columns import ColumnSpec
from .table import TColumnDef
[docs]
def column_sort_key(
header_defs: Sequence[TColumnDef],
sort_columns: Sequence[str] | None = None,
cell_key: Callable[[str | None], Any] | None = None,
) -> Callable[[Sequence[str | None]], tuple] | None:
"""Build a row sort key from the `sort_columns` a table actually carries.
`header_defs` describes the rendered columns:
{class}`~click_extra.columns.ColumnSpec` instances, with `sortable=False` for
columns that cannot be sorted on. The requested `sort_columns` the table carries
drive the comparison first, de-duplicated and in request order; the remaining
columns follow in their natural left-to-right order for tie-breaking.
Returns `None` when the table carries none of the requested columns,
signalling that rows should keep their original order. This is what lets
one `--sort-by` selection apply across subcommands rendering
heterogeneous tables: each table sorts by the requested fields it knows,
and a table knowing none of them is left untouched.
"""
return _defs_sort_key(
tuple(_normalize_column_def(c) for c in header_defs), sort_columns, cell_key
)
def _column_sort_key(
header_defs: Sequence[tuple[str | None, str | None]],
sort_columns: Sequence[str] | None = None,
cell_key: Callable[[str | None], Any] | None = None,
) -> Callable[[Sequence[str | None]], tuple]:
"""Like {func}`column_sort_key`, but always sorts.
When none of the `sort_columns` is carried by `header_defs` (or none
is requested), rows still sort, comparing every column in natural
left-to-right order. This is the key {class}`SortByOption` publishes on
the context (`click_extra.context.TABLE_SORT_KEY`) when its column
definitions are known at declaration time.
"""
key = _defs_sort_key(header_defs, sort_columns, cell_key)
if key is None:
key = _row_sort_key(range(len(header_defs)), cell_key)
return key
[docs]
class ColumnsType(MultiChoice):
"""{class}`~click_extra.types.MultiChoice` over the IDs of table columns.
Pins the comma separator and case-sensitive matching (column IDs are
snake_case identifiers, not free-form strings), and renames the metavar
fallback to `COLUMNS` instead of the generic `MULTI`.
"""
name = "columns"
def __init__(self, choices: Sequence[str] = ()) -> None:
super().__init__(choices=choices, separator=",", case_sensitive=True)
[docs]
class ColumnsOption(ExtraOption):
"""A `--columns` option that lets users restrict and reorder table columns.
Accepts a comma-separated list of column IDs, SQL-`SELECT`-style:
```{code-block} shell-session
$ my-cli --columns id,spec,value --params
```
The selection is stored in
{data}`ctx.meta[click_extra.context.COLUMNS] <click_extra.context.COLUMNS>` and
consumed by table-rendering callbacks (like
{class}`click_extra.show_params.ShowParamsOption`) to project rows + headers
before rendering.
Pass `columns=` at construction time with the column registry the option
should advertise: the help text then lists the accepted IDs and the default
selection, and the callback validates the user input against that registry
so unknown IDs fail fast with a {class}`click.UsageError`. Without
`columns=`, the option stays generic: it parses any IDs and leaves
validation to the downstream consumer.
Empty / unset means *render every column in canonical order*: the default
behavior, indistinguishable from not passing `--columns` at all.
"""
def __init__(
self,
param_decls: Sequence[str] | None = None,
*,
columns: Sequence[ColumnSpec] | None = None,
type: click.ParamType | Any | None = None,
metavar: str = "COLUMNS",
default: Sequence[str] | None = (),
expose_value: bool = False,
is_eager: bool = True,
help: str = _(
"Restrict and reorder table columns, SQL SELECT-style. "
"Comma-separated list of column IDs. Default: all columns in "
"canonical order.",
),
**kwargs: Any,
) -> None:
if not param_decls:
param_decls = ("--columns",)
self.columns: tuple[ColumnSpec, ...] = tuple(columns or ())
"""Column registry this option advertises and validates against (may be empty)."""
# The registry still reaches the type, which validates against it and
# reports the IDs to every doc render. The help screen shows the
# `COLUMNS` placeholder instead of the enumeration: a table wide enough
# to be worth projecting carries more IDs than a metavar can hold, and
# the unwrappable line pushes the description off the column and past
# the terminal's width. The `man`, `markdown` and `json` renders list
# them, see `DocOptionItem.unlisted_choices`.
if type is None:
type = ColumnsType(choices=tuple(c.id for c in self.columns))
kwargs.setdefault("callback", self.init_columns)
super().__init__(
param_decls=param_decls,
type=type,
metavar=metavar,
default=default,
expose_value=expose_value,
help=help,
is_eager=is_eager,
**kwargs,
)
[docs]
def init_columns(
self,
ctx: click.Context,
param: click.Parameter,
columns: tuple[str, ...],
) -> None:
"""Store the selected column IDs on the context for later projection.
{meth}`~click_extra.types.MultiChoice.convert` validates the IDs against
the registry before this callback runs, so this only threads the parsed
selection onto the context.
"""
if ctx.resilient_parsing:
return
context.set(ctx, context.COLUMNS, tuple(columns or ()))
[docs]
class SortByOption(ExtraOption):
"""A `--sort-by` option whose choices are derived from column definitions.
Stores the selected column IDs in `ctx.meta[click_extra.context.SORT_BY]`
and publishes the derived row sort key in
`ctx.meta[click_extra.context.TABLE_SORT_KEY]`, which `ctx.print_table`
picks up so that table output is automatically sorted, without changing its
`(table_data, headers)` call contract. The option accepts
`multiple=True`, so users can repeat `--sort-by` to define a
multi-column sort priority.
Column definitions are `ColumnSpec` instances, passed positionally or via
the `columns=` keyword. A column marked `sortable=False` keeps its place in
the layout without being offered as a choice. Passing a `ColumnSpec`
registry via `columns=` lets the same tuple drive both `ColumnsOption`
(`--columns`) and `--sort-by`, so the two options stay in sync from a
single source of truth.
```{code-block} python
COLUMNS = (
ColumnSpec("package_id", "Package ID"),
ColumnSpec("package_name", "Name"),
ColumnSpec("manager_id", "Manager"),
)
@command
@table_format_option
@columns_option(columns=COLUMNS)
@sort_by_option(columns=COLUMNS)
@pass_context
def my_cmd(ctx):
ctx.print_table(rows, [col.label for col in COLUMNS])
```
Definitions may instead be bare column ID strings, declaring a
**field vocabulary** untied to any single table layout. This fits a
`--sort-by` declared once on a group whose subcommands render
heterogeneous tables: no sort key is published since no layout is known up
front. The selection is resolved per table by
{func}`~click_extra.table.print_table`, from
the column IDs its headers carry: each table sorts by the selected fields
it knows (remaining columns breaking ties left to right) and keeps its
original row order when it knows none.
```{code-block} python
@group
@sort_by_option("package_id", "package_name", "manager_id")
def my_cli():
pass
@my_cli.command
def installed():
print_table(
rows,
[
ColumnSpec("package_id", "Package ID"),
ColumnSpec("manager_id", "Manager"),
],
)
@my_cli.command
def managers():
print_table(rows, [ColumnSpec("manager_id", "Manager"), "Path"])
```
"""
def __init__(
self,
*header_defs: TColumnDef,
param_decls: Sequence[str] | None = None,
columns: Sequence[TColumnDef] | None = None,
default: str | Sequence[str] | None = None,
expose_value: bool = False,
cell_key: Callable[[str | None], Any] | None = None,
help: str = _("Sort table by this column. Repeat to set priority."),
**kwargs: Any,
) -> None:
if not param_decls:
param_decls = ("--sort-by",)
# Accept a shared `columns=` registry (the same `ColumnSpec` tuple
# passed to `--columns`) or positional definitions. Each entry is a
# `ColumnSpec` or a bare column ID string.
if columns is not None and header_defs:
msg = "Pass column definitions positionally or via columns=, not both."
raise TypeError(msg)
raw_defs = columns if columns is not None else header_defs
self.header_defs = tuple(_normalize_column_def(c) for c in raw_defs)
self.cell_key = cell_key
# Bare column IDs declare a table-less field vocabulary; labeled
# definitions describe one table's layout. Mixing both would leave the
# labeled part baking a sort over a layout the vocabulary part denies.
labeled = [d for d in self.header_defs if d[0] is not None]
if labeled and len(labeled) != len(self.header_defs):
msg = "Pass either bare column IDs or labeled column definitions, not both."
raise TypeError(msg)
self.field_vocabulary = bool(self.header_defs) and not labeled
"""Whether definitions are bare column IDs, untied to any table layout.
In this mode {meth}`init_sort` only publishes the selection on the
context: the sort is resolved per table at
{func}`~click_extra.table.print_table` time.
"""
sortable_ids = [col_id for _, col_id in self.header_defs if col_id]
# Normalize default to a tuple for multiple mode. `None` derives the
# first sortable ID; an explicit empty sequence declares no default
# sort, so bare invocations keep the original row order.
if default is None:
default = (sortable_ids[0],) if sortable_ids else ()
elif isinstance(default, str):
default = (default,)
else:
default = tuple(default)
kwargs.setdefault("callback", self.init_sort)
super().__init__(
param_decls=param_decls,
type=click.Choice(sortable_ids, case_sensitive=False),
default=default,
expose_value=expose_value,
help=help,
multiple=True,
**kwargs,
)
[docs]
def init_sort(
self,
ctx: click.Context,
param: click.Parameter,
sort_columns: tuple[str, ...],
) -> None:
"""Publish the row sort key on the context's shared `meta`.
Builds the sort key from this option's column definitions and the
selected `sort_columns`, then stores it under
`ctx.meta[click_extra.context.TABLE_SORT_KEY]`, where
`ctx.print_table` picks it up. The call contract is the same sorted
or not: `ctx.print_table(table_data, headers)`.
In field-vocabulary mode no table layout is known at declaration time,
so no key is published: only the selection lands on the context
(`ctx.meta` is shared with every subcommand), resolved per table by
{func}`~click_extra.table.print_table` from the column IDs its headers carry.
"""
if ctx.resilient_parsing:
return
context.set(ctx, context.SORT_BY, sort_columns)
if self.field_vocabulary:
return
sort_key = _column_sort_key(self.header_defs, sort_columns, self.cell_key)
context.set(ctx, context.TABLE_SORT_KEY, sort_key)
# Same foreign-command escape hatch as TableFormatOption.init_formatter:
# rebind the instance attribute with the sort baked in, since a plain
# click.Context has no meta-reading print_table method.
if not isinstance(ctx, commands.Context):
ctx.print_table = partial( # type: ignore[attr-defined]
print_table,
table_format=context.get(ctx, context.TABLE_FORMAT),
sort_key=sort_key,
)