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 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 # Control how shared libraries are located at runtime on Linux. See the
# the Spack documentation for details. # the Spack documentation for details.
shared_linking: shared_linking:

View File

@ -4,12 +4,14 @@
import argparse import argparse
import difflib import difflib
import functools
import importlib import importlib
import os import os
import re import re
import subprocess
import sys import sys
from collections import Counter from collections import Counter
from typing import List, Optional, Union from typing import Callable, List, Optional, Union
import llnl.string import llnl.string
import llnl.util.tty as tty import llnl.util.tty as tty
@ -30,6 +32,7 @@
import spack.store import spack.store
import spack.traverse as traverse import spack.traverse as traverse
import spack.user_environment as uenv import spack.user_environment as uenv
import spack.util.executable as exe
import spack.util.spack_json as sjson import spack.util.spack_json as sjson
import spack.util.spack_yaml as syaml import spack.util.spack_yaml as syaml
@ -723,3 +726,123 @@ def __init__(self, cmd_name):
long_msg += "\n ".join(similar) long_msg += "\n ".join(similar)
super().__init__(msg, long_msg) 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.filesystem as fs
import llnl.util.tty as tty import llnl.util.tty as tty
import spack.cmd
import spack.config import spack.config
import spack.environment as ev import spack.environment as ev
import spack.error 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) syaml.dump_config(flattened, stream=sys.stdout, default_flow_style=False, blame=blame)
@spack.cmd.paged
def config_get(args): def config_get(args):
"""Dump merged YAML configuration for a specific section. """Dump merged YAML configuration for a specific section.
@ -178,6 +180,7 @@ def config_get(args):
print_configuration(args, blame=False) print_configuration(args, blame=False)
@spack.cmd.paged
def config_blame(args): def config_blame(args):
"""Print out line-by-line blame of merged YAML.""" """Print out line-by-line blame of merged YAML."""
print_configuration(args, blame=True) print_configuration(args, blame=True)

View File

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

View File

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

View File

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

View File

@ -374,6 +374,12 @@ def make_argument_parser(**kwargs):
choices=("always", "never", "auto"), choices=("always", "never", "auto"),
help="when to colorize output (default: 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( parser.add_argument(
"-c", "-c",
"--config", "--config",
@ -536,6 +542,10 @@ def setup_main_options(args):
if args.timestamp: if args.timestamp:
tty.set_timestamp(True) 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 # override lock configuration if passed on command line
if args.locks is not None: if args.locks is not None:
if args.locks is False: if args.locks is False:

View File

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