# Copyright Kevin Deldycke <[email protected]> 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.
"""Table-output vocabulary and rendering plumbing shared by the subcommands.
The {command}`mpm` subcommands render heterogeneous tables (different columns per
command) but share the same output machinery. This module owns all of it:
- {class}`SortableField`, the vocabulary of the global `mpm --sort-by`
selector. The selector itself is click-extra's field-vocabulary
{class}`~click_extra.table.SortByOption`, and the per-table resolution (sort
by the selected fields the table carries, keep the original row order when it
carries none) happens inside {func}`click_extra.table.print_table`, from the
field each header pairs with its column in the registries below.
- The per-command **column registries**, each pairing a click-extra
{class}`~click_extra.columns.ColumnSpec` (whose ID addresses the column from
`--columns`) with the {class}`SortableField` the column carries (`None`
for a column that cannot drive the sort). A registry is the single source of
truth for its command: the same tuple feeds the `@columns_option` declaration
(which validates the user selection) and {func}`print_projected_table`
(which projects headers and rows before rendering).
- {func}`print_projected_table` and {func}`print_serialized`, the
human-friendly and machine-friendly rendering paths every table-producing
subcommand goes through. The read commands serialize through
{func}`print_serialized_and_exit`, which stops the command there.
"""
from __future__ import annotations
import logging
import shutil
import sys
from contextlib import contextmanager
from click_extra import ColumnSpec, print_data, select_columns, select_row
from click_extra.context import COLUMNS, TABLE_FORMAT
from click_extra.table import AUTO_WIDTH, SERIALIZATION_FORMATS
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from backports.strenum import StrEnum
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from click_extra import Context
[docs]
class SortableField(StrEnum):
"""Fields IDs allowed to be sorted."""
MANAGER_ID = "manager_id"
MANAGER_NAME = "manager_name"
PACKAGE_ID = "package_id"
PACKAGE_NAME = "package_name"
VERSION = "version"
[docs]
class PackageOutcome(StrEnum):
"""What a command did to one package, as the `status` column of
{data}`CHANGE_REPORT_COLUMNS` spells it.
Classified by {func}`meta_package_manager.cli.package_outcomes` from the
inventory a manager reports before and after the command changes it. The
values are the words the serialized payload carries and the trail line
counts (`1 upgraded, 2 held`), so a member reads as a word rather than as an
identifier. The table shows each one as its {attr}`label`.
"""
UPGRADED = "upgraded"
"""The installed version moved to a newer release, or in a direction that
`mpm` cannot tell."""
DOWNGRADED = "downgraded"
"""The installed version moved back to an older release."""
INSTALLED = "installed"
"""Absent before the command: a package it installed, or a dependency it
pulled in."""
REMOVED = "removed"
"""Present before the command, gone after it."""
HELD = "held"
"""Still outdated because the release-age cooldown holds it back."""
STILL_OUTDATED = "still outdated"
"""Still outdated for any other reason: a failed build, a pinned package, or
one the native command leaves alone."""
@property
def label(self) -> str:
"""The outcome as the report table shows it: its glyph from
{data}`PACKAGE_OUTCOME_GLYPHS`, then its word."""
return f"{PACKAGE_OUTCOME_GLYPHS[self]} {self.value}"
PACKAGE_OUTCOME_GLYPHS: dict[PackageOutcome, str] = {
PackageOutcome.UPGRADED: "π",
PackageOutcome.DOWNGRADED: "βͺ",
PackageOutcome.INSTALLED: "π",
PackageOutcome.REMOVED: "ποΈ",
PackageOutcome.HELD: "βΈοΈ",
PackageOutcome.STILL_OUTDATED: "β³",
}
"""The glyph leading each {class}`PackageOutcome` in the report table.
All but one come from the legend repomatic's dependency reports use for the
same moves (π updated, βͺ stepped back, π new, ποΈ removed, βΈοΈ held back by
cooldown), so a reader of both reads one legend. `still outdated` has no
counterpart there: β³ marks a package whose upgrade is still pending, whatever
the cause.
Only the table carries them. The serialized report and the trail line keep the
bare word, which is what a script matches.
"""
TColumn = tuple[ColumnSpec, "SortableField | None"]
"""One column of a registry: its spec, and the field it sorts on, if any."""
PACKAGE_ID_COLUMN: TColumn = (
ColumnSpec("package_id", "Package ID", "Package's identifier."),
SortableField.PACKAGE_ID,
)
"""The `package_id` column every package table opens on.
Deliberately the one column of every package table left uncapped, and the rule
holds wherever it is reused. It is the value the user copies back into an
`mpm install`, `mpm remove` or `mpm upgrade` invocation, and a cell wrapped over
two lines cannot be selected in one go. So it never wraps, and the shrinking
falls on its neighbors instead: the name is free prose, and a version is read
rather than retyped.
The trade is explicit. A package ID wider than the terminal on its own still
pushes the table past the edge, because the alternative is handing the user a
broken identifier. Both halves of the trade are real here: `vim-pack` names its
plugins by GitHub URL (50 characters), and Homebrew Cask reports versions like
`1.26832.0,056ee2be623b207f6a4d24dfb1b2fb5a82db0ecf`.
"""
PACKAGE_NAME_COLUMN: TColumn = (
ColumnSpec(
"package_name",
"Name",
"Package's common name.",
max_width=AUTO_WIDTH,
),
SortableField.PACKAGE_NAME,
)
"""The `package_name` column following {data}`PACKAGE_ID_COLUMN` in every
package table, wrapping inside its own cell."""
MANAGERS_COLUMNS: tuple[TColumn, ...] = (
(
ColumnSpec("manager_id", "Manager ID", "Manager's identifier."),
SortableField.MANAGER_ID,
),
(
ColumnSpec("manager_name", "Name", "Manager's common name."),
SortableField.MANAGER_NAME,
),
(
ColumnSpec(
"supported",
"Supported",
"Support status on the current platform.",
max_width=AUTO_WIDTH,
),
None,
),
(
ColumnSpec(
"cli",
"CLI",
"Location of the manager's binary on the system.",
max_width=AUTO_WIDTH,
),
None,
),
(
ColumnSpec("executable", "Executable", "Whether the binary is executable."),
None,
),
(
ColumnSpec(
"version",
"Version",
"Manager's self-reported version, and the unsatisfied requirement "
"when stale.",
),
SortableField.VERSION,
),
)
"""Columns of the `mpm managers` table.
`supported` and `cli` are the two whose content no manager bounds: the first
enumerates every platform a manager runs on when they do not collapse to a
group name, the second holds a filesystem path. Both take
{data}`~click_extra.table.AUTO_WIDTH` so they share whatever the fixed columns
leave on the terminal and wrap inside their own cell, instead of stretching the
table past the edge and mangling every border.
"""
MANAGERS_DETECTED_COLUMNS: tuple[str, ...] = (
"manager_id",
"manager_name",
"cli",
"version",
)
"""Columns kept by the default, detected-only view of the `mpm managers` table.
A detected manager is by definition supported on this platform, found and
executable, so `supported` and `executable` render the same β on every row and
carry no information. Both come back in the wider views, where an unsupported
platform or a missing binary makes them vary again. Only the *default* selection
narrows: `--columns` still addresses every column of {data}`MANAGERS_COLUMNS`.
"""
INSTALLED_COLUMNS: tuple[TColumn, ...] = (
PACKAGE_ID_COLUMN,
PACKAGE_NAME_COLUMN,
(
ColumnSpec("manager_id", "Manager", "Manager reporting the package."),
SortableField.MANAGER_ID,
),
(
ColumnSpec(
"installed_version",
"Installed version",
"Version currently installed.",
max_width=AUTO_WIDTH,
),
SortableField.VERSION,
),
)
"""Columns of the `mpm installed` table, and of the `mpm orphans` one.
The width policy is the one {data}`PACKAGE_ID_COLUMN` documents: the ID never
wraps, every other column does.
"""
OUTDATED_COLUMNS: tuple[TColumn, ...] = (
*INSTALLED_COLUMNS,
(
ColumnSpec(
"latest_version",
"Latest version",
"Version available for upgrade.",
max_width=AUTO_WIDTH,
),
None,
),
)
"""Columns of the `mpm outdated` table.
Inherits the width policy documented on {data}`PACKAGE_ID_COLUMN`: the second
version column wraps like the first, `package_id` still does not.
"""
SEARCH_COLUMNS: tuple[TColumn, ...] = (
PACKAGE_ID_COLUMN,
PACKAGE_NAME_COLUMN,
(
ColumnSpec("manager_id", "Manager", "Manager reporting the match."),
SortableField.MANAGER_ID,
),
(
ColumnSpec(
"latest_version",
"Latest version",
"Latest version available.",
max_width=AUTO_WIDTH,
),
SortableField.VERSION,
),
(
ColumnSpec(
"description",
"Description",
"Package description, for managers that provide one. Out of the "
"default selection: select it explicitly or pass --description.",
max_width=AUTO_WIDTH,
),
None,
),
)
"""Columns of the `mpm search` table.
The `description` column exists in the registry (so `--columns` can select it)
but stays out of the default selection unless `--description` (or `--extended`,
which searches descriptions) is passed.
It is the column holding the longest free prose, of a length no manager bounds:
a single verbose match used to stretch the table far past the terminal and wrap
every row at the edge, mangling the borders.
{data}`~click_extra.table.AUTO_WIDTH` caps it at whatever the other columns
leave on the terminal, so the description wraps inside its own cell. The name
and version columns share that treatment, and `package_id` is exempt from it,
per the width policy documented on {data}`PACKAGE_ID_COLUMN`.
"""
CHANGE_REPORT_COLUMNS: tuple[TColumn, ...] = (
PACKAGE_ID_COLUMN,
PACKAGE_NAME_COLUMN,
(
ColumnSpec("manager_id", "Manager", "Manager whose inventory changed."),
SortableField.MANAGER_ID,
),
(
ColumnSpec(
"from_version",
"From",
"Version installed before the command.",
max_width=AUTO_WIDTH,
),
None,
),
(
ColumnSpec(
"to_version",
"To",
"Version installed after the command, or the one still available for "
"a package that did not move.",
max_width=AUTO_WIDTH,
),
None,
),
(
ColumnSpec(
"status",
"Status",
f"What the command did to the package: {', '.join(PackageOutcome)}.",
),
None,
),
)
"""Columns of the change report closing every command that changes the installed
inventory: `install`, `remove`, `upgrade`, `restore` and `cleanup --orphans`.
One row per package the command moved or should have, each carrying one
{class}`PackageOutcome`; see {func}`meta_package_manager.cli.package_outcomes`
for how a row is classified. The version columns wrap like every other table's,
and `package_id` does not, per {data}`PACKAGE_ID_COLUMN`. Neither version column
drives `--sort-by`: a report sorts by what moved, not by how far.
"""
WHICH_COLUMNS: tuple[TColumn, ...] = (
(
ColumnSpec(
"manager_id", "Manager ID", "Manager whose search path found the binary."
),
SortableField.MANAGER_ID,
),
(
ColumnSpec(
"priority", "Priority", "Rank of the match in the manager's search path."
),
None,
),
(
ColumnSpec("cli_path", "CLI path", "Location of the matched binary."),
None,
),
(
ColumnSpec(
"symlink",
"Symlink destination",
"Resolved target when the match is a symlink.",
),
None,
),
)
"""Columns of the `mpm which` table."""
[docs]
def column_specs(columns: Sequence[TColumn]) -> tuple[ColumnSpec, ...]:
"""Extract the bare {class}`~click_extra.columns.ColumnSpec` tuple from a column
registry."""
return tuple(spec for spec, _ in columns)
@contextmanager
def _terminal_width_budget(ctx: Context) -> Iterator[None]:
"""Give a table the whole terminal, then put the help budget back.
click-extra sizes every {data}`~click_extra.table.AUTO_WIDTH` column from
`ctx.make_formatter().width`, the same budget that lays out help screens.
Click caps that at 80 columns unless `max_content_width` says otherwise, which
is right for prose and far too tight for a data table: the fixed columns alone
can exceed it, leaving each auto column at
{data}`~click_extra.table.MIN_COLUMN_WIDTH` however wide the terminal is, so a
path wraps every eight characters on a 200-column screen.
Raising the cap on the `mpm` group instead was measured and rejected. mpm's
help text is hand-formatted against an 80-character reference, and a large
cap stops Click wrapping altogether: `mpm --help` then emitted a 463-character
line. So the widening is scoped to the render and undone after it, leaving
every help screen untouched.
"""
previous = ctx.max_content_width
ctx.max_content_width = shutil.get_terminal_size().columns
try:
yield
finally:
ctx.max_content_width = previous
[docs]
def print_projected_table(
ctx: Context,
columns: Sequence[TColumn],
rows: Iterable[dict[str, str | None]],
default_ids: Sequence[str] | None = None,
) -> None:
"""Render dict `rows` as a table projected through `--columns`.
The `--columns` selection restricts and reorders the rendering,
SQL-`SELECT`-style; click-extra's
{class}`~click_extra.table.ColumnsOption` already validated it against the
same `columns` registry, so unknown IDs never reach this point.
`default_ids` is the selection applied when the user passed none
(`search` uses it to hide the description column unless
`--description`); `None` keeps every column in canonical order.
Sorting stays on mpm's global `--sort-by`: each header pairs its label
with the sortable field the column carries, and click-extra's
{meth}`~click_extra.context.Context.print_table` reads the selection
(with the `--table-format` one) from the shared context `meta` and
resolves it per table. A sort field whose column is projected out is simply
skipped, and a table carrying none of the selected fields keeps its
original row order.
```{note}
A rendered header addresses its column by the field it sorts on, not by the
ID `--columns` selects it with: the two can differ, and a column may sort on
nothing at all. {func}`sort_header` builds each one.
```
"""
selected = ctx.meta.get(COLUMNS) or tuple(default_ids or ())
projected = select_columns(column_specs(columns), selected)
sort_field = {spec.id: field for spec, field in columns}
ids = tuple(spec.id for spec in projected)
with _terminal_width_budget(ctx):
ctx.print_table(
[select_row(row, ids, ids) for row in rows],
tuple(sort_header(spec, sort_field[spec.id]) for spec in projected),
)
[docs]
def print_serialized(ctx: Context, data: object) -> bool:
"""Render `data` in the active serialization format, if one is active.
When the global `--table-format` resolves to one of the structured
serialization formats (JSON, YAML, TOML, XML, ...), serialize `data` under
the shared `mpm` root element and return `True`. Otherwise print nothing and
return `False`, so the caller falls through to its human-friendly rendering.
"""
table_format = ctx.meta[TABLE_FORMAT]
if table_format not in SERIALIZATION_FORMATS:
return False
# Serialized documents carry the full structured payload, which a
# --columns selection does not narrow.
if ctx.meta.get(COLUMNS):
logging.info(
"Ignore the --columns option: serialized output carries every field."
)
print_data(data, table_format, root_element="mpm", package="meta-package-manager")
return True
[docs]
def print_serialized_and_exit(ctx: Context, data: object) -> None:
"""Render `data` in the active serialization format, then exit.
The read commands' variant of {func}`print_serialized`: their serialized
document is their whole output, so the command stops there. Otherwise
return, so the caller falls through to its human-friendly table rendering.
"""
if print_serialized(ctx, data):
ctx.exit()