diff --git a/etc/spack/defaults/config.yaml b/etc/spack/defaults/config.yaml index 7d6f444b7e3..55dad99fe1a 100644 --- a/etc/spack/defaults/config.yaml +++ b/etc/spack/defaults/config.yaml @@ -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: diff --git a/lib/spack/spack/cmd/__init__.py b/lib/spack/spack/cmd/__init__.py index 030a95c2388..970509918f0 100644 --- a/lib/spack/spack/cmd/__init__.py +++ b/lib/spack/spack/cmd/__init__.py @@ -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 diff --git a/lib/spack/spack/cmd/config.py b/lib/spack/spack/cmd/config.py index d13e5bafb68..b23a573c920 100644 --- a/lib/spack/spack/cmd/config.py +++ b/lib/spack/spack/cmd/config.py @@ -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) diff --git a/lib/spack/spack/cmd/find.py b/lib/spack/spack/cmd/find.py index 8a1100303a8..a8dba7d05bd 100644 --- a/lib/spack/spack/cmd/find.py +++ b/lib/spack/spack/cmd/find.py @@ -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() diff --git a/lib/spack/spack/cmd/info.py b/lib/spack/spack/cmd/info.py index 5fd32762721..7733690a8eb 100644 --- a/lib/spack/spack/cmd/info.py +++ b/lib/spack/spack/cmd/info.py @@ -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) diff --git a/lib/spack/spack/cmd/list.py b/lib/spack/spack/cmd/list.py index 5982c78a18b..a0de4bc1a69 100644 --- a/lib/spack/spack/cmd/list.py +++ b/lib/spack/spack/cmd/list.py @@ -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("\n") +@spack.cmd.paged def list(parser, args): # retrieve the formatter to use from args formatter = formatters[args.format] diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index 458ef7d2671..bca524ebd1d 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -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: diff --git a/lib/spack/spack/schema/config.py b/lib/spack/spack/schema/config.py index b1f9a2e9bd0..8240fd414a0 100644 --- a/lib/spack/spack/schema/config.py +++ b/lib/spack/spack/schema/config.py @@ -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": [ {