diff --git a/pyproject.toml b/pyproject.toml index 5f9905985..6a1aa2b32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,8 @@ requires-python = ">=3.10,<4.0" dependencies = [ "gevent>=1.5", "paramiko>=2.11,<5", # 2.11 (2022) adds Transport.open_channel(timeout=...) for ProxyJump timeout (#971) - "click>2", "cyclopts>=4,<5", + "rich>=13", "jinja2>3,<4", "python-dateutil>2,<3", "typeguard>=4,<5", @@ -50,7 +50,6 @@ repository = "https://github.com/pyinfra-dev/pyinfra" [dependency-groups] test = [ - "click>=8.2", "pytest>=8.3.5,<9", "freezegun>=1.5.5", "coverage>=7.7.1,<8", diff --git a/src/pyinfra/api/output.py b/src/pyinfra/api/output.py index d7a1bcb68..5f4787edb 100644 --- a/src/pyinfra/api/output.py +++ b/src/pyinfra/api/output.py @@ -3,14 +3,50 @@ Provides ``format_text`` and ``echo`` functions that default to plain-text no-ops, allowing the API layer to work without any CLI dependency. The CLI -layer replaces them at startup via ``set_formatter`` and ``set_echo``. +layer replaces them at startup via ``set_formatter`` and ``set_echo`` (wiring +in Rich-backed implementations). """ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from collections.abc import Callable +if TYPE_CHECKING: + from rich.console import Console + +_console: Console | None = None + + +def get_console() -> Console: + """ + Return the shared human-facing (stderr) Rich console. + + Created lazily so importing the API layer doesn't require Rich to be + configured. The CLI may replace it via :func:`set_console` to share a + single console between logging, tables and the progress spinner. + """ + global _console + if _console is None: + from rich.console import Console + + # markup/emoji disabled: host print prefixes like ``[@fake/host]`` and + # arbitrary command output must not be interpreted as Rich markup. + _console = Console( + stderr=True, + highlight=False, + soft_wrap=True, + markup=False, + emoji=False, + ) + return _console + + +def set_console(console: Console) -> None: + """Replace the shared human-facing console.""" + global _console + _console = console + # Default formatter: identity function (returns plain text, ignores styling kwargs). def _default_format_text(text: str, *args: Any, **kwargs: Any) -> str: @@ -29,25 +65,26 @@ def _default_echo(message: Any = None, **kwargs: Any) -> None: def format_text(text: str, *args: Any, **kwargs: Any) -> str: """Format text with optional styling (color, bold, etc.). - Mirrors ``click.style`` signature. Accepts positional ``fg`` argument - for compatibility with ``click.style("text", "red")``. + Historically mirrored ``click.style``: accepts a positional foreground + color (e.g. ``format_text("text", "red")``) and ``bold=`` keyword. The CLI + installs a Rich-backed implementation preserving this signature. """ return _format_text(text, *args, **kwargs) def echo(message: Any = None, **kwargs: Any) -> None: - """Echo a message. Mirrors ``click.echo`` signature.""" + """Echo a message. Supports ``err=True`` to write to stderr.""" return _echo(message, **kwargs) def set_formatter(func: Callable[..., str]) -> None: - """Replace the default formatter (e.g. with ``click.style``).""" + """Replace the default formatter (e.g. with a Rich-backed styler).""" global _format_text _format_text = func def set_echo(func: Callable[..., None]) -> None: - """Replace the default echo function (e.g. with ``click.echo``).""" + """Replace the default echo function (e.g. with a Rich-backed echo).""" global _echo _echo = func diff --git a/src/pyinfra_cli/cli.py b/src/pyinfra_cli/cli.py index 7789adddc..3194b6c63 100644 --- a/src/pyinfra_cli/cli.py +++ b/src/pyinfra_cli/cli.py @@ -9,8 +9,8 @@ from os import chdir as os_chdir, environ, getcwd from pathlib import Path -import click from cyclopts import App, Group, Parameter +from rich.prompt import Confirm from pyinfra import __version__, logger, state from pyinfra.api import Config, Host, Inventory, State @@ -26,6 +26,7 @@ from pyinfra.api.output import format_text from .commands import get_facts_and_args, get_func_and_args +from .console import console, stdout_console from .exceptions import CliError, UnexpectedExternalError, UnexpectedInternalError, WrappedError from .inventory import make_inventory from .log import setup_logging @@ -65,11 +66,71 @@ def _lenient_bool(type_, tokens) -> bool: raise ValueError(f"invalid boolean value: {tokens[0].value!r}") +_EXAMPLES = """\ +# Run one or more deploys against the inventory +pyinfra INVENTORY deploy_web.py [deploy_db.py]... + +# Run a single operation against the inventory +pyinfra INVENTORY server.user pyinfra home=/home/pyinfra + +# Execute an arbitrary command against the inventory +pyinfra INVENTORY exec -- echo "hello world" + +# Run one or more facts against the inventory +pyinfra INVENTORY fact server.LinuxName [server.Users]... +pyinfra INVENTORY fact files.File path=/path/to/file... + +# Debug the inventory hosts and data +pyinfra INVENTORY debug-inventory""" + + +def _build_examples_epilogue() -> str: + """Render the CLI examples as syntax-highlighted (bash) ANSI text. + + Used as a ``help_format="rich"`` epilogue so the examples show up + colourised on the help page. + """ + from rich.syntax import Syntax + + syntax = Syntax(_EXAMPLES, "bash", background_color="default", word_wrap=True) + with stdout_console.capture() as capture: + stdout_console.print("[bold]Examples:[/bold]\n") + stdout_console.print(syntax) + return capture.get() + + +def _build_usage() -> str: + """Colourised usage line: required args in cyan, optionals dimmed. + + Rendered to an ANSI string because Cyclopts concatenates ``usage`` as a + plain string; the ``Usage:`` label is added by the help formatter. Colour + is only emitted when stdout is a terminal so piped output stays plain. + """ + from rich.text import Text + + line = Text.assemble( + ("pyinfra ", "bold"), + ("[OPTIONS] ", "dim"), + ("INVENTORY ", "bold cyan"), + ("[OPERATIONS...]", "cyan"), + ) + if not stdout_console.is_terminal: + return line.plain + with stdout_console.capture() as capture: + stdout_console.print(line, end="") + return capture.get() + + app = App( name="pyinfra", version=f"pyinfra: v{__version__}", version_flags=["--version"], help_flags=["-h", "--help"], + help_format="rich", + usage=_build_usage(), + console=stdout_console, + error_console=console, + help_epilogue=_build_examples_epilogue(), ) # Enable ``pyinfra --install-completion`` for shell autocompletion. @@ -169,30 +230,14 @@ def cli( """pyinfra manages the state of one or more servers. It can be used for app/service deployment, config management and ad-hoc - command execution. Documentation: docs.pyinfra.com - - INVENTORY is a file (inventory.py), a hostname (host.net) or comma separated - hostnames (host-1.net,host-2.net,@local). + command execution. - Examples: + Documentation: [cyan][link=https://docs.pyinfra.com]docs.pyinfra.com[/link][/cyan] - ``` - # Run one or more deploys against the inventory - pyinfra INVENTORY deploy_web.py [deploy_db.py]... - - # Run a single operation against the inventory - pyinfra INVENTORY server.user pyinfra home=/home/pyinfra - - # Execute an arbitrary command against the inventory - pyinfra INVENTORY exec -- echo "hello world" - - # Run one or more facts against the inventory - pyinfra INVENTORY fact server.LinuxName [server.Users]... - pyinfra INVENTORY fact files.File path=/path/to/file... - - # Debug the inventory hosts and data - pyinfra INVENTORY debug-inventory - ``` + INVENTORY can be: + - a file ([cyan]inventory.py[/cyan]) + - a hostname ([cyan]host.net[/cyan]) + - comma separated hostnames ([cyan]host-1.net,host-2.net,@local[/cyan]) Parameters ---------- @@ -489,13 +534,12 @@ def _main( else: logger.info("--> Detected changes:") print_meta(state) - click.echo( + console.print( """ Detected changes may not include every change pyinfra will execute. Hidden side effects of operations may alter behaviour of future operations, this will be shown in the results. The remote state will always be updated to reflect the state defined by the input operations.""", - err=True, ) # If --debug-facts or --debug-operations, print and exit @@ -536,29 +580,15 @@ def _main( def _do_confirm(msg: str) -> bool: - click.echo(err=True) - click.echo(f" {msg}", err=True) + console.print() + console.print(f" {msg}") warning_count = state.get_warning_counter() if warning_count > 0: - click.secho( + console.print( f" {warning_count} warnings shown during change detection, see above", - fg="yellow", - err=True, + style="yellow", ) - confirm_msg = " Press enter to execute..." - click.echo(confirm_msg, err=True, nl=False) - v = input() - if v: - click.echo(f" Unexpected user input: {v}", err=True) - return False - # Go up, clear the line, go up again - as if the confirmation statement was never here! - click.echo( - "\033[1A{}\033[1A".format("".join(" " for _ in range(len(confirm_msg)))), - err=True, - nl=False, - ) - click.echo(err=True) - return True + return Confirm.ask(" Execute?", console=console, default=True) # Setup diff --git a/src/pyinfra_cli/console.py b/src/pyinfra_cli/console.py new file mode 100644 index 000000000..8fb719eae --- /dev/null +++ b/src/pyinfra_cli/console.py @@ -0,0 +1,70 @@ +""" +Shared Rich consoles and Click-compatible output adapters for the CLI. + +pyinfra keeps all human-facing output on **stderr** and reserves **stdout** for +machine-readable (``--json``) payloads. The core library styles/echoes text +through :mod:`pyinfra.api.output`; here we install Rich-backed implementations. +""" + +from __future__ import annotations + +from typing import Any + +from rich.console import Console +from rich.text import Text + +from pyinfra.api.output import get_console, set_console + +# Human-facing console (logs, tables, prompts, spinner) → stderr. +# Reuse the core shared console so the progress spinner and logging write to the +# same Console instance (avoids Live-region corruption). +console = get_console() +set_console(console) + +# Machine-readable console (``--json`` payloads) → stdout, no styling. +stdout_console = Console(highlight=False, soft_wrap=True, markup=False, emoji=False) + + +def format_text(text: str, fg: str | None = None, *, bold: bool = False, **kwargs: Any) -> str: + """ + Style ``text`` and return a string with embedded ANSI codes. + + Mirrors the legacy ``click.style`` signature (positional foreground color + + ``bold=``) used across the core library, but renders via Rich so styling is + consistent with the rest of the CLI output. Colour names (``red``, + ``green``, ...) are passed straight through to Rich. + """ + style_bits = [] + if fg is not None: + style_bits.append(fg) + if bold: + style_bits.append("bold") + + if not style_bits: + return text + + rich_text = Text(text, style=" ".join(style_bits)) + with console.capture() as capture: + console.print(rich_text, end="") + return capture.get() + + +def echo(message: Any = None, *, err: bool = False, nl: bool = True, **kwargs: Any) -> None: + """ + Print ``message`` to the appropriate console. + + ``err=True`` targets the (default) human stderr console; ``err=False`` + targets stdout. ``nl=False`` suppresses the trailing newline. Text may + contain ANSI escape codes already produced by :func:`format_text`. + """ + target = console if err else stdout_console + end = "\n" if nl else "" + + if message is None: + target.print("", end=end) + return + + if isinstance(message, str): + target.print(Text.from_ansi(message), end=end) + else: + target.print(message, end=end) diff --git a/src/pyinfra_cli/exceptions.py b/src/pyinfra_cli/exceptions.py index 9f2d3205b..c8643554b 100644 --- a/src/pyinfra_cli/exceptions.py +++ b/src/pyinfra_cli/exceptions.py @@ -1,12 +1,13 @@ -import abc import sys from inspect import getframeinfo -from traceback import format_exception, format_tb, walk_tb -from types import TracebackType +from traceback import walk_tb +from types import ModuleType, TracebackType -import click +from rich.console import Console +from rich.traceback import Traceback from typing_extensions import override +import pyinfra from pyinfra import logger from pyinfra.api.exceptions import ( ArgumentTypeError, @@ -16,6 +17,29 @@ ) from pyinfra.api.util import PYINFRA_INSTALL_DIR +from .console import console, format_text + +# Modules whose frames are collapsed in rendered tracebacks so the user's deploy +# code stands out rather than pyinfra/gevent/cyclopts internals. +_TRACEBACK_SUPPRESS: list[str | ModuleType] = ["gevent", "cyclopts", pyinfra] + + +def _rich_traceback(exc: BaseException) -> Traceback: + """Build a Rich ``Traceback`` for a wrapped exception. + + The wrapping ``CliException`` stashes the live traceback on the original + exception as ``_traceback``; fall back to ``__traceback__`` just in case. + """ + tb = getattr(exc, "_traceback", None) or exc.__traceback__ + return Traceback.from_exception( + type(exc), + exc, + tb, + suppress=_TRACEBACK_SUPPRESS, + show_locals=False, + word_wrap=True, + ) + def get_frame_line_from_tb(tb: TracebackType): frame_lines = list(walk_tb(tb)) @@ -27,7 +51,24 @@ def get_frame_line_from_tb(tb: TracebackType): return info -class WrappedError(click.ClickException): +class CliException(Exception): + """Base for pyinfra CLI errors, carrying a user-facing ``message``.""" + + message: str + + def __init__(self, message: str = ""): + self.message = message + super().__init__(message) + + @override + def __str__(self) -> str: + return self.message + + def show(self) -> None: + raise NotImplementedError + + +class WrappedError(CliException): def __init__(self, e: Exception): self.traceback = e.__traceback__ self.exception = e @@ -36,10 +77,10 @@ def __init__(self, e: Exception): message = getattr(e, "message", e.args[0]) if not isinstance(message, str): message = repr(message) - self.message = message + super().__init__(message) @override - def show(self, file=None): + def show(self) -> None: name = "unknown error" if isinstance(self.exception, ConnectorDataTypeError): @@ -59,45 +100,31 @@ def show(self, file=None): name = f"{name} in {info.filename} line {info.lineno}" logger.warning( - f"--> {click.style(name, 'red', bold=True)}: {self}", + f"--> {format_text(name, 'red', bold=True)}: {self}", ) -class CliError(click.ClickException): +class CliError(CliException): @override - def show(self, file=None): + def show(self) -> None: logger.warning( - f"--> {click.style('pyinfra error', 'red', bold=True)}: {self}", + f"--> {format_text('pyinfra error', 'red', bold=True)}: {self}", ) -class UnexpectedMixin(abc.ABC): - exception: Exception - traceback: TracebackType - - def get_traceback_lines(self): - traceback = getattr(self.exception, "_traceback") - return format_tb(traceback) - - def get_traceback(self): - return "".join(self.get_traceback_lines()) - - def get_exception(self): - return "".join(format_exception(self.exception.__class__, self.exception, None)) - - -class UnexpectedExternalError(click.ClickException, UnexpectedMixin): +class UnexpectedExternalError(CliException): def __init__(self, e, filename): _, _, traceback = sys.exc_info() e._traceback = traceback self.exception = e self.filename = filename + super().__init__(str(e)) @override - def show(self, file=None): + def show(self) -> None: logger.warning( "--> {}:\n".format( - click.style( + format_text( f"An exception occurred in: {self.filename}", "red", bold=True, @@ -105,56 +132,42 @@ def show(self, file=None): ), ) - click.echo("Traceback (most recent call last):", err=True) - click.echo(self.get_traceback(), err=True, nl=False) - click.echo(self.get_exception(), err=True) + console.print(_rich_traceback(self.exception)) -class UnexpectedInternalError(click.ClickException, UnexpectedMixin): +class UnexpectedInternalError(CliException): def __init__(self, e): _, _, traceback = sys.exc_info() e._traceback = traceback self.exception = e + super().__init__(str(e)) @override - def show(self, file=None): - click.echo( + def show(self) -> None: + console.print( "--> {}:\n".format( - click.style( + format_text( "An internal exception occurred", "red", bold=True, ), ), - err=True, ) - traceback_lines = self.get_traceback_lines() - traceback = self.get_traceback() - - # Syntax errors contain the filename/line/etc, but other exceptions - # don't, so print the *last* call to stderr. - if not isinstance(self.exception, SyntaxError): - sys.stderr.write(traceback_lines[-1]) - - exception = self.get_exception() - click.echo(exception, err=True) + traceback = _rich_traceback(self.exception) + console.print(traceback) + # Persist an uncoloured copy of the same traceback for bug reports. with open("pyinfra-debug.log", "w", encoding="utf-8") as f: - f.write(traceback) - f.write(exception) + file_console = Console(file=f, width=100, force_terminal=False, no_color=True) + file_console.print(traceback) - logger.debug(traceback) - logger.debug(exception) + logger.debug(str(self.exception)) - click.echo( - f"--> The full traceback has been written to {click.style('pyinfra-debug.log', bold=True)}", - err=True, + console.print( + f"--> The full traceback has been written to {format_text('pyinfra-debug.log', bold=True)}", ) - click.echo( - ( - "--> If this is unexpected please consider submitting a bug report " - "on GitHub, for more information run `pyinfra --support`." - ), - err=True, + console.print( + "--> If this is unexpected please consider submitting a bug report " + "on GitHub, for more information run `pyinfra --support`." ) diff --git a/src/pyinfra_cli/log.py b/src/pyinfra_cli/log.py index ece7e0c5b..a64a48e41 100644 --- a/src/pyinfra_cli/log.py +++ b/src/pyinfra_cli/log.py @@ -1,18 +1,21 @@ import logging -import click +from rich.text import Text from typing_extensions import override from pyinfra import logger, state from pyinfra.context import ctx_state +from .console import console, format_text + class LogHandler(logging.Handler): @override def emit(self, record): try: message = self.format(record) - click.echo(message, err=True) + # ``message`` may already contain ANSI escape codes (from format_text). + console.print(Text.from_ansi(message)) except Exception: self.handleError(record) @@ -21,10 +24,10 @@ class LogFormatter(logging.Formatter): previous_was_header = True level_to_format = { - logging.DEBUG: lambda s: click.style(s, "green"), - logging.WARNING: lambda s: click.style(s, "yellow"), - logging.ERROR: lambda s: click.style(s, "red"), - logging.CRITICAL: lambda s: click.style(s, "red", bold=True), + logging.DEBUG: lambda s: format_text(s, "green"), + logging.WARNING: lambda s: format_text(s, "yellow"), + logging.ERROR: lambda s: format_text(s, "red"), + logging.CRITICAL: lambda s: format_text(s, "red", bold=True), } @override @@ -50,7 +53,7 @@ def format(self, record): if "-->" in message: if not self.previous_was_header: - click.echo(err=True) + console.print() else: message = f" {message}" diff --git a/src/pyinfra_cli/main.py b/src/pyinfra_cli/main.py index 289fbb228..5c3ee3e04 100644 --- a/src/pyinfra_cli/main.py +++ b/src/pyinfra_cli/main.py @@ -1,37 +1,35 @@ import signal import sys -import click import gevent import pyinfra from pyinfra.api.output import set_echo, set_formatter from .cli import app +from .console import console, echo, format_text +from .exceptions import CliException def main(): # Set CLI mode pyinfra.is_cli = True - # Wire click's styling/echo into the API output layer - set_formatter(click.style) - set_echo(click.echo) + # Wire Rich-backed styling/echo into the API output layer + set_formatter(format_text) + set_echo(echo) # Don't write out deploy.pyc/config.pyc etc sys.dont_write_bytecode = True sys.path.append(".") - # Shut it click - click.disable_unicode_literals_warning = True # type: ignore - # Force line buffering sys.stdout.reconfigure(line_buffering=True) # type: ignore sys.stderr.reconfigure(line_buffering=True) # type: ignore def _handle_interrupt(signum, frame): - click.echo("Exiting upon user request!") + console.print("Exiting upon user request!") sys.exit(0) try: @@ -45,6 +43,6 @@ def _handle_interrupt(signum, frame): try: app() - except click.ClickException as e: + except CliException as e: e.show() - sys.exit(e.exit_code) + sys.exit(1) diff --git a/src/pyinfra_cli/prints.py b/src/pyinfra_cli/prints.py index 625017aef..f85cfa4c9 100644 --- a/src/pyinfra_cli/prints.py +++ b/src/pyinfra_cli/prints.py @@ -4,14 +4,20 @@ import platform import re import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from collections.abc import Callable, Iterator -import click +from rich.console import Group +from rich.json import JSON +from rich.padding import Padding +from rich.table import Table +from rich.text import Text from pyinfra import __version__, logger from pyinfra.api.host import Host +from pyinfra.api.output import format_text +from .console import console, stdout_console from .util import json_encode if TYPE_CHECKING: @@ -53,8 +59,50 @@ def jsonify(data, *args, **kwargs): return json.dumps(data, *args, **kwargs) +def _safe_encode(obj: Any) -> Any: + """``json_encode`` fallback that never raises (for values). + + Used for the human ``debug-inventory`` rendering, where a value that is + neither natively JSON-serialisable nor handled by ``json_encode`` (e.g. a + compiled ``re.Pattern``) should degrade to its ``str()`` rather than + aborting the whole command. The ``--json`` path keeps using the strict + ``json_encode`` so machine output stays valid JSON. + """ + try: + return json_encode(obj) + except TypeError: + return str(obj) + + +def _json_safe_keys(value: Any) -> Any: + """Recursively coerce non-primitive mapping keys to ``str``. + + ``json.dumps`` rejects dict keys that are not ``str``/``int``/``float``/ + ``bool``/``None`` *before* the ``default`` hook runs, so a ``re.Pattern`` + used as a ``fake_responses`` matcher key would still raise. This makes the + human ``debug-inventory`` rendering robust against such keys. + """ + if isinstance(value, dict): + return { + (key if isinstance(key, (str, int, float, bool)) or key is None else str(key)): ( + _json_safe_keys(val) + ) + for key, val in value.items() + } + if isinstance(value, (list, tuple)): + return [_json_safe_keys(item) for item in value] + return value + + def print_json(payload) -> None: - click.echo(jsonify(payload, default=json_encode)) + json_str = jsonify(payload, default=json_encode) + + # When stdout is a real terminal, pretty-print + syntax-highlight the JSON. + # When piped/redirected, emit plain JSON so it stays machine-parseable. + if stdout_console.is_terminal: + stdout_console.print(JSON(json_str)) + else: + print(json_str) def _host_to_dict(host: Host) -> dict: @@ -192,23 +240,22 @@ def print_run_json(state: State, dry: bool) -> None: def print_state_operations(state: State): state_ops = {host: ops for host, ops in state.ops.items() if state.is_host_in_limit(host)} - click.echo(err=True) - click.echo("--> Operations:", err=True) - click.echo(jsonify(state_ops, indent=4, default=json_encode), err=True) - click.echo(err=True) - click.echo("--> Operation meta:", err=True) - click.echo(jsonify(state.op_meta, indent=4, default=json_encode), err=True) + console.print() + console.print("--> Operations:") + console.print(jsonify(state_ops, indent=4, default=json_encode)) + console.print() + console.print("--> Operation meta:") + console.print(jsonify(state.op_meta, indent=4, default=json_encode)) - click.echo(err=True) - click.echo("--> Operation order:", err=True) - click.echo(err=True) + console.print() + console.print("--> Operation order:") + console.print() for op_hash in state.get_op_order(): meta = state.op_meta[op_hash] hosts = set(host for host, operations in state.ops.items() if op_hash in operations) - click.echo( + console.print( f" {op_hash} (names={meta.names}, hosts={hosts})", - err=True, ) @@ -222,9 +269,8 @@ def print_groups_by_comparison(print_items, comparator=lambda item: item[0]): items.append(name) else: - click.echo( - f" {', '.join(click.style(name, bold=True) for name in items)}", - err=True, + console.print( + f" {', '.join(format_text(name, bold=True) for name in items)}", ) items = [name] @@ -232,31 +278,94 @@ def print_groups_by_comparison(print_items, comparator=lambda item: item[0]): last_name = name if items: - click.echo( - f" {', '.join(click.style(name, bold=True) for name in items)}", - err=True, + console.print( + f" {', '.join(format_text(name, bold=True) for name in items)}", ) def print_fact(fact_data): - click.echo(jsonify(fact_data, indent=4, default=json_encode), err=True) + console.print(jsonify(fact_data, indent=4, default=json_encode)) + + +def _scalar_style(value: Any) -> str: + """Rich style for a scalar, matching the JSON highlighter's type colours. + + Non-JSON scalars (datetime, Path, ``re.Pattern``, arbitrary objects) render + unstyled, since they are shown via ``str()`` rather than as JSON values. + """ + # NOTE: bool is a subclass of int, so it must be checked first. + if isinstance(value, bool): + return "json.bool_true" if value else "json.bool_false" + if value is None: + return "json.null" + if isinstance(value, (int, float)): + return "json.number" + if isinstance(value, str): + return "json.str" + return "" + + +def _format_host_data(data: dict) -> Group: + """Render host data as one ``key: value`` line per top-level key. + + Scalars are shown inline; nested ``dict``/``list``/``tuple`` values are + rendered as indented JSON (syntax-highlighted). Any other value (datetime, + Path, ``re.Pattern``, arbitrary objects) falls back to ``str()`` so the + display never fails on non-JSON-serialisable data. Insertion order is + preserved. + """ + if not data: + return Group(Text("(no data)", style="dim")) + + lines: list[Any] = [] + for key, value in data.items(): + label = Text(f"{key}: ", style="bold blue") + if isinstance(value, (dict, list, tuple)): + # Nested structures: header line + indented JSON below it. + lines.append(Text.assemble(label)) + value_json = jsonify(_json_safe_keys(value), indent=2, default=_safe_encode) + lines.append(Padding(JSON(value_json), (0, 0, 0, 2))) + else: + # Scalars inline, coloured to match Rich's JSON highlighter (booleans + # green/red, numbers cyan, null magenta, strings green). Other values + # (datetime, Path, re.Pattern, arbitrary objects) fall back to an + # unstyled str() so the display never fails on non-JSON data. + lines.append(Text.assemble(label, (str(value), _scalar_style(value)))) + + return Group(*lines) def print_inventory(state: State): + table = Table( + title="Inventory", + title_style="bold", + header_style="bold", + expand=True, + leading=1, + ) + # Only the data column flexes; host/groups stay as narrow as their content. + table.add_column("Host", style="cyan", no_wrap=True, ratio=None) + table.add_column("Groups", style="green", no_wrap=True, ratio=None) + table.add_column("Data", ratio=1) + for host in state.inventory: - click.echo(err=True) - click.echo(host.print_prefix, err=True) - click.echo(f"--> Groups: {', '.join(host.groups)}", err=True) - click.echo("--> Data:", err=True) - click.echo(jsonify(host.data, indent=4, default=json_encode), err=True) + # A host may appear in the same group more than once (e.g. connector + + # inventory group); de-duplicate for display while preserving order. + groups = list(dict.fromkeys(host.groups)) + table.add_row( + host.name, + "\n".join(groups), + _format_host_data(host.data.dict()), + ) + + console.print(table) def print_facts(facts): for name, data in facts.items(): - click.echo(err=True) - click.echo( - f"--> Fact data for: {click.style(name, bold=True)}", - err=True, + console.print() + console.print( + f"--> Fact data for: {format_text(name, bold=True)}", ) print_fact(data) @@ -266,7 +375,7 @@ def print_support_info() -> None: from packaging.requirements import Requirement - click.echo( + console.print( """ If you are having issues with pyinfra or wish to make feature requests, please check out the GitHub issues at https://github.com/Fizzadar/pyinfra/issues . @@ -274,11 +383,11 @@ def print_support_info() -> None: """, ) - click.echo(f" System: {platform.system()}", err=True) - click.echo(f" Platform: {platform.platform()}", err=True) - click.echo(f" Release: {platform.uname()[2]}", err=True) - click.echo(f" Machine: {platform.uname()[4]}", err=True) - click.echo(f" pyinfra: v{__version__}", err=True) + console.print(f" System: {platform.system()}") + console.print(f" Platform: {platform.platform()}") + console.print(f" Release: {platform.uname()[2]}") + console.print(f" Machine: {platform.uname()[4]}") + console.print(f" pyinfra: v{__version__}") seen_reqs: set[str] = set() for requirement_string in sorted(requires("pyinfra") or []): @@ -287,18 +396,16 @@ def print_support_info() -> None: continue seen_reqs.add(requirement.name) try: - click.echo( + console.print( f" {requirement.name}: v{version(requirement.name)}", - err=True, ) except PackageNotFoundError: # package not installed in this environment continue - click.echo(f" Executable: {sys.argv[0]}", err=True) - click.echo( + console.print(f" Executable: {sys.argv[0]}") + console.print( f" Python: {platform.python_version()} ({platform.python_implementation()}, {platform.python_compiler()})", - err=True, ) diff --git a/src/pyinfra_cli/util.py b/src/pyinfra_cli/util.py index 5bea669f4..cdaefd306 100644 --- a/src/pyinfra_cli/util.py +++ b/src/pyinfra_cli/util.py @@ -9,7 +9,6 @@ from types import CodeType, FunctionType, ModuleType from collections.abc import Callable -import click import gevent from pyinfra import logger, state @@ -17,6 +16,7 @@ from pyinfra.api.exceptions import PyinfraError from pyinfra.api.host import HostData from pyinfra.api.operation import OperationMeta +from pyinfra.api.output import format_text from pyinfra.api.state import ( State, StateHostMeta, @@ -221,7 +221,7 @@ def load_file(local_host): with ctx_host.use(local_host): callback() logger.info( - f"{local_host.print_prefix}{click.style('Ready:', 'green')} {click.style(name, bold=True)}", + f"{local_host.print_prefix}{format_text('Ready:', 'green')} {format_text(name, bold=True)}", ) except Exception as e: return e diff --git a/src/pyinfra_cli/virtualenv.py b/src/pyinfra_cli/virtualenv.py index ed59916bd..eed0fa1bb 100644 --- a/src/pyinfra_cli/virtualenv.py +++ b/src/pyinfra_cli/virtualenv.py @@ -2,10 +2,10 @@ import sys from pathlib import Path -import click - from pyinfra import logger +from .console import console + def init_virtualenv() -> None: """ @@ -62,7 +62,7 @@ def init_virtualenv() -> None: " If you encounter problems, please install pyinfra inside the virtualenv." ), ) - click.echo(err=True) + console.print() if sys.platform == "win32": virtual_env = str(Path(os.environ["VIRTUAL_ENV"]) / "Lib" / "site-packages") diff --git a/tests/test_cli/test_cli.py b/tests/test_cli/test_cli.py index e4ee19701..2349d5e3f 100644 --- a/tests/test_cli/test_cli.py +++ b/tests/test_cli/test_cli.py @@ -17,6 +17,59 @@ def test_print_help(self): result = run_cli("--help") assert result.exit_code == 0, result.stderr + def test_support_standalone(self): + # `pyinfra --support` must work without INVENTORY/OPERATIONS: the crash + # handler tells users to run exactly this. + result = run_cli("--support") + assert result.exit_code == 0, result.stderr + assert "pyinfra: v" in result.stderr + + +class TestCliYesEnvVar(TestCase): + def _parse_yes(self, value): + import os + + from pyinfra_cli.cli import app + + os.environ["PYINFRA_YES"] = value + try: + _, bound, _ = app.parse_args(["inv.py", "server.shell", "x"], exit_on_error=False) + return bound.arguments["yes"] + finally: + del os.environ["PYINFRA_YES"] + + def test_empty_is_false(self): + assert self._parse_yes("") is False + + def test_whitespace_is_false(self): + assert self._parse_yes(" ") is False + + def test_on_off(self): + assert self._parse_yes("on") is True + assert self._parse_yes("off") is False + + def test_numeric(self): + assert self._parse_yes("1") is True + assert self._parse_yes("0") is False + + def test_true_false_any_case(self): + assert self._parse_yes("true") is True + assert self._parse_yes("False") is False + + def test_invalid_value_errors(self): + import os + + from cyclopts import CycloptsError + + from pyinfra_cli.cli import app + + os.environ["PYINFRA_YES"] = "junk" + try: + with self.assertRaises(CycloptsError): + app.parse_args(["inv.py", "x"], exit_on_error=False, print_error=False) + finally: + del os.environ["PYINFRA_YES"] + class TestOperationCli(PatchSSHTestCase): def test_invalid_operation_module(self): diff --git a/tests/test_cli/test_cli_prints.py b/tests/test_cli/test_cli_prints.py new file mode 100644 index 000000000..9be48e843 --- /dev/null +++ b/tests/test_cli/test_cli_prints.py @@ -0,0 +1,87 @@ +import re +from datetime import datetime +from pathlib import PurePosixPath +from unittest import TestCase +from unittest.mock import patch + +from rich.console import Console + +from pyinfra.api import Config, State + +import pyinfra_cli.prints as prints_module +from pyinfra_cli.console import console +from pyinfra_cli.prints import _format_host_data, _scalar_style, print_inventory + +from ..util import make_inventory + + +def _render_inventory(host_data: dict) -> str: + inventory = make_inventory(hosts=(("somehost", host_data),)) + state = State(inventory, Config()) + # Use a wide, fixed-width console so the table is never truncated (the shared + # console's width varies by platform/terminal, which would clip cell content). + wide_console = Console(width=200, force_terminal=False, highlight=False) + with patch.object(prints_module, "console", wide_console): + with wide_console.capture() as capture: + print_inventory(state) + return capture.get() + + +class TestPrintInventory(TestCase): + def test_scalars_render_as_flat_key_value_lines(self): + output = _render_inventory({"role": "web", "port": 80, "enabled": True}) + + assert "role: web" in output + assert "port: 80" in output + assert "enabled: True" in output + # Scalars must NOT be dumped as JSON (no quoted keys/values). + assert '"role"' not in output + assert '"web"' not in output + + def test_nested_values_render_as_json(self): + output = _render_inventory({"tags": ["a", "b"], "meta": {"cpu": 4}}) + + # Header line for the key, then indented JSON for the value. + assert "tags:" in output + assert '"a"' in output and '"b"' in output + assert "meta:" in output + assert '"cpu": 4' in output + + def test_non_json_scalars_render_via_str(self): + created = datetime(2021, 8, 14, 10, 30) + # PurePosixPath keeps str() stable across platforms (WindowsPath would + # render with backslashes). + path = PurePosixPath("/opt/app") + output = _render_inventory({"created": created, "path": path}) + + assert f"created: {created}" in output + assert f"path: {path}" in output + + def test_re_pattern_does_not_crash(self): + # Regression: a compiled regex (as a nested dict key) previously crashed + # the whole `debug-inventory` command trying to JSON-encode it. + output = _render_inventory( + {"fake_responses": {re.compile(r"^pip"): {"success": False}}}, + ) + + assert "fake_responses:" in output + assert "success" in output + + def test_empty_data(self): + # `make_inventory` always injects some data, so exercise the helper + # directly for the empty case. + with console.capture() as capture: + console.print(_format_host_data({})) + assert "(no data)" in capture.get() + + def test_scalar_styling_matches_json_highlighter(self): + # Scalars are coloured by type to match Rich's JSON highlighter. + assert _scalar_style(True) == "json.bool_true" + assert _scalar_style(False) == "json.bool_false" + assert _scalar_style(None) == "json.null" + assert _scalar_style(80) == "json.number" + assert _scalar_style(1.5) == "json.number" + assert _scalar_style("web") == "json.str" + # Non-JSON scalars render unstyled (shown via str()). + assert _scalar_style(datetime(2021, 8, 14)) == "" + assert _scalar_style(PurePosixPath("/opt/app")) == "" diff --git a/tests/test_cli/util.py b/tests/test_cli/util.py index e27c500e0..94d9cf4b2 100644 --- a/tests/test_cli/util.py +++ b/tests/test_cli/util.py @@ -2,10 +2,10 @@ from io import StringIO from os import chdir, getcwd -import click - import pyinfra +import pyinfra_cli.console as cli_console from pyinfra_cli.cli import app +from pyinfra_cli.exceptions import CliException class CliResult: @@ -26,26 +26,34 @@ def run_cli(*arguments): stdout_buffer = StringIO() stderr_buffer = StringIO() + # The whole CLI (cli/prints/log/virtualenv/exceptions/progress) shares the + # single console instance from pyinfra_cli.console, so redirecting its file + # captures all human output. Machine-readable (--json) output goes to real + # stdout via print(), captured with redirect_stdout below. + console = cli_console.console + stdout_console = cli_console.stdout_console + original_console_file = console.file + original_stdout_console_file = stdout_console.file + console.file = stderr_buffer + stdout_console.file = stdout_buffer + exit_code = 0 exception = None try: - with ( - contextlib.redirect_stdout(stdout_buffer), - contextlib.redirect_stderr(stderr_buffer), - ): - try: - app(list(arguments), exit_on_error=False) - except click.ClickException as e: - exception = e - e.show() - exit_code = e.exit_code + with contextlib.redirect_stdout(stdout_buffer): + app(list(arguments), exit_on_error=False) except SystemExit as e: exit_code = e.code if isinstance(e.code, int) else (0 if e.code is None else 1) - except BaseException as e: # surface any error to the test as .exception + except CliException as e: + exception = e + exit_code = 1 + except BaseException as e: # noqa: B036 - surface any error to the test as .exception exception = e exit_code = 1 finally: + console.file = original_console_file + stdout_console.file = original_stdout_console_file pyinfra.is_cli = False chdir(cwd) diff --git a/uv.lock b/uv.lock index e00027530..f7b21630d 100644 --- a/uv.lock +++ b/uv.lock @@ -1389,7 +1389,6 @@ wheels = [ name = "pyinfra" source = { editable = "." } dependencies = [ - { name = "click" }, { name = "cyclopts" }, { name = "distro" }, { name = "gevent" }, @@ -1398,6 +1397,7 @@ dependencies = [ { name = "paramiko" }, { name = "pydantic" }, { name = "python-dateutil" }, + { name = "rich" }, { name = "typeguard" }, { name = "types-paramiko" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, @@ -1405,7 +1405,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "click" }, { name = "coverage" }, { name = "freezegun" }, { name = "ipdb" }, @@ -1433,7 +1432,6 @@ docs = [ { name = "zensical" }, ] test = [ - { name = "click" }, { name = "coverage" }, { name = "freezegun" }, { name = "mypy" }, @@ -1451,7 +1449,6 @@ test = [ [package.metadata] requires-dist = [ - { name = "click", specifier = ">2" }, { name = "cyclopts", specifier = ">=4,<5" }, { name = "distro", specifier = ">=1.6,<2" }, { name = "gevent", specifier = ">=1.5" }, @@ -1460,6 +1457,7 @@ requires-dist = [ { name = "paramiko", specifier = ">=2.11,<5" }, { name = "pydantic", specifier = ">=2.11,<3" }, { name = "python-dateutil", specifier = ">2,<3" }, + { name = "rich", specifier = ">=13" }, { name = "typeguard", specifier = ">=4,<5" }, { name = "types-paramiko", specifier = ">=2.7,<5" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, @@ -1467,7 +1465,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "click", specifier = ">=8.2" }, { name = "coverage", specifier = ">=7.7.1,<8" }, { name = "freezegun", specifier = ">=1.5.5" }, { name = "ipdb" }, @@ -1494,7 +1491,6 @@ docs = [ { name = "zensical", specifier = ">=0.0.34" }, ] test = [ - { name = "click", specifier = ">=8.2" }, { name = "coverage", specifier = ">=7.7.1,<8" }, { name = "freezegun", specifier = ">=1.5.5" }, { name = "mypy", specifier = "==1.17.1" },