Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
51 changes: 44 additions & 7 deletions src/pyinfra/api/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
118 changes: 74 additions & 44 deletions src/pyinfra_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
----------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions src/pyinfra_cli/console.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading