Source code for meta_package_manager.tables

# 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)
[docs] def sort_header(spec: ColumnSpec, field: SortableField | None) -> ColumnSpec: """The header `spec` renders under, keyed by the field it sorts on. click-extra reads a header's ID as the field `--sort-by` addresses. For mpm that is the {class}`SortableField` the column carries, not the column's own ID: `installed_version` sorts on `version`. A column carrying no field keeps its own ID and declares `sortable=False`, which drops it from the selection. The spec carries its own `max_width`, which click-extra reads off the header to size the column. """ return ColumnSpec( field or spec.id, spec.label, max_width=spec.max_width, sortable=bool(field), )
@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