commands: add support for paged output

Signed-off-by: Todd Gamblin <tgamblin@llnl.gov>
This commit is contained in:
Todd Gamblin 2025-02-23 15:26:08 -08:00 committed by Harmen Stoppels
parent b932c14008
commit 81a1f97779
8 changed files with 147 additions and 1 deletions

View File

@ -178,6 +178,10 @@ config:
package_lock_timeout: null
# pager(s) to use for commands with potentially long output (e.g., spack info)
pager:
- less -FXRS
# Control how shared libraries are located at runtime on Linux. See the
# the Spack documentation for details.
shared_linking:

View File

@ -4,12 +4,14 @@
import argparse
import difflib
import functools
import importlib
import os
import re
import subprocess
import sys
from collections import Counter
from typing import List, Optional, Union
from typing import Callable, List, Optional, Union
import llnl.string
import llnl.util.tty as tty
@ -30,6 +32,7 @@
import spack.store
import spack.traverse as traverse
import spack.user_environment as uenv
import spack.util.executable as exe
import spack.util.spack_json as sjson
import spack.util.spack_yaml as syaml
@ -723,3 +726,123 @@ def __init__(self, cmd_name):
long_msg += "\n ".join(similar)
super().__init__(msg, long_msg)
def find_pager(pager_candidates: List[str]) -> Optional[List[str]]:
"""Find a pager from spack configuration.
Arguments:
pager_candidates: list of candidate commands with optional arguments, e.g. "less -FXRS"
Returns:
Arguments, including the found command, to launch the pager, or None if not found.
"""
for pager in pager_candidates:
# split each string in the list of pagers into args
argv = pager.split()
if not argv:
continue
# try to find the requested pager command
command, *args = argv
path = exe.which_string(command)
if not path:
continue
# return the execv args we need to launch this thing
return [command] + args
return None
def spack_pager_candidates() -> List[str]:
"""Get a list of pager candidates by consulting environment and config.
Order of precedence is:
1. ``SPACK_PAGER``: pager just for spack
2. ``PAGER``: user's preferred pager, from their environment
3. ``config:pager``: list of pager candidates in config
"""
pager_candidates = []
spack_pager = os.environ.get("SPACK_PAGER")
if spack_pager:
pager_candidates.append(spack_pager)
pager = os.environ.get("PAGER")
if pager:
pager_candidates.append(pager)
config_pagers = spack.config.get("config:pager")
if config_pagers:
pager_candidates.extend(config_pagers)
return pager_candidates
def paged(command_function: Callable) -> Callable:
"""Decorator for commands whose output should be sent to a pager by default.
This will launch a subprocess for, e.g., ``less``, and will redirect ``stdout`` to it, as
``git`` does for commands like ``git log``.
The command will attempt to maintain colored output while paging, so you need a pager
that supports color, like ``less -R``. Spack defaults to using ``less -FXRS`` if it's
found, and nothing if not. You probably *do not* want to use ``more`` or any other
non-color-capable pager.
"""
pager_execv_args = find_pager(spack_pager_candidates())
if not pager_execv_args:
return command_function
@functools.wraps(command_function)
def wrapper(*args, **kwargs):
# figure out if we're running the command with --help
is_help = False
if args and isinstance(args[-1], argparse.Namespace):
is_help = args[-1].help
# don't page if not a tty, and don't page help output
if not sys.stdout.isatty() or is_help:
return command_function(*args, **kwargs)
# Flush any buffered output before redirection.
sys.stdout.flush()
# save original stdout and original color setting
original_stdout_fd = os.dup(sys.stdout.fileno())
original_stdout_isatty = sys.stdout.isatty
# launch the pager
proc = subprocess.Popen(pager_execv_args, stdin=subprocess.PIPE)
try:
# Redirect stdout's file descriptor to the pager's stdin.
os.dup2(proc.stdin.fileno(), sys.stdout.fileno())
# make spack think the pager is a tty
sys.stdout.isatty = lambda: True
# run the decorated function
result = command_function(*args, **kwargs)
# Flush any remaining output.
sys.stdout.flush()
return result
finally:
# quit cheating on isatty
sys.stdout.isatty = original_stdout_isatty
# restore stdout
os.dup2(original_stdout_fd, sys.stdout.fileno())
os.close(original_stdout_fd)
# Close the pager's stdin and wait for it to finish.
proc.stdin.close()
proc.wait()
return wrapper

View File

@ -10,6 +10,7 @@
import llnl.util.filesystem as fs
import llnl.util.tty as tty
import spack.cmd
import spack.config
import spack.environment as ev
import spack.error
@ -169,6 +170,7 @@ def print_flattened_configuration(*, blame: bool) -> None:
syaml.dump_config(flattened, stream=sys.stdout, default_flow_style=False, blame=blame)
@spack.cmd.paged
def config_get(args):
"""Dump merged YAML configuration for a specific section.
@ -178,6 +180,7 @@ def config_get(args):
print_configuration(args, blame=False)
@spack.cmd.paged
def config_blame(args):
"""Print out line-by-line blame of merged YAML."""
print_configuration(args, blame=True)

View File

@ -363,6 +363,7 @@ def _find_query(args, env):
return results, concretized_but_not_installed
@cmd.paged
def find(parser, args):
env = ev.active_environment()

View File

@ -11,6 +11,7 @@
from llnl.util.tty.colify import colify
import spack.builder
import spack.cmd
import spack.deptypes as dt
import spack.fetch_strategy as fs
import spack.install_test
@ -481,6 +482,7 @@ def print_licenses(pkg, args):
color.cprint(line)
@spack.cmd.paged
def info(parser, args):
spec = spack.spec.Spec(args.package)
pkg_cls = spack.repo.PATH.get_pkg_class(spec.fullname)

View File

@ -15,6 +15,7 @@
import llnl.util.tty as tty
from llnl.util.tty.colify import colify
import spack.cmd
import spack.deptypes as dt
import spack.package_base
import spack.repo
@ -315,6 +316,7 @@ def head(n, span_id, title, anchor=None):
out.write("</div>\n")
@spack.cmd.paged
def list(parser, args):
# retrieve the formatter to use from args
formatter = formatters[args.format]

View File

@ -374,6 +374,12 @@ def make_argument_parser(**kwargs):
choices=("always", "never", "auto"),
help="when to colorize output (default: auto)",
)
parser.add_argument(
"--no-pager",
action="store_true",
default=False,
help="do not run any output through a pager",
)
parser.add_argument(
"-c",
"--config",
@ -536,6 +542,10 @@ def setup_main_options(args):
if args.timestamp:
tty.set_timestamp(True)
# override pager configuration (note ::)
if args.no_pager:
spack.config.set("config::pager", [], scope="command_line")
# override lock configuration if passed on command line
if args.locks is not None:
if args.locks is False:

View File

@ -104,6 +104,7 @@
"additional_external_search_paths": {"type": "array", "items": {"type": "string"}},
"binary_index_ttl": {"type": "integer", "minimum": 0},
"aliases": {"type": "object", "patternProperties": {r"\w[\w-]*": {"type": "string"}}},
"pager": {"type": "array", "items": {"type": "string"}},
},
"deprecatedProperties": [
{