Skip to content
Closed
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
19 changes: 10 additions & 9 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pyinfra is an extremely powerful tool for ad-hoc execution and management of rem
As described in the [getting started page](./getting-started), pyinfra needs an **inventory** and some **operations**. These are used with the CLI as below:

```sh
Usage: pyinfra [OPTIONS] INVENTORY OPERATIONS...
Usage: pyinfra [OPTIONS] INVENTORY [OPERATIONS...]

# INVENTORY

Expand Down Expand Up @@ -222,14 +222,15 @@ Interactive prompts from deploy code (e.g. `input(...)`) will still block, so av

## Shell Autocompletion

Add the following to your `~/.bash_profile` or `~/.profile` files:
pyinfra's CLI is built on [Cyclopts](https://cyclopts.readthedocs.io), which can install
shell completion for you:

+ **bash** `source scripts/pyinfra-complete.sh`.
+ **zsh** `source scripts/pyinfra-complete.zsh`.

These files were generated using these commands:
```sh
# Install completion for your current shell (bash, zsh, fish, ...)
pyinfra --install-completion

# Or target a specific shell
pyinfra --install-completion --shell zsh
```
env _PYINFRA_COMPLETE=bash_source pyinfra > pyinfra-complete.sh
env _PYINFRA_COMPLETE=zsh_source pyinfra > pyinfra-complete.zsh
```

After installing you may need to restart your shell or source its configuration file.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +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 @@ -49,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
21 changes: 0 additions & 21 deletions scripts/pyinfra-complete.sh

This file was deleted.

28 changes: 0 additions & 28 deletions scripts/pyinfra-complete.zsh

This file was deleted.

4 changes: 2 additions & 2 deletions src/pyinfra/api/connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def connect_all(state: "State"):

greenlet_to_host = {state.pool.spawn(host.connect): host for host in hosts}

with progress_spinner(greenlet_to_host.values()) as progress:
with progress_spinner(greenlet_to_host.values(), prefix_message="Connecting") as progress:
for greenlet in gevent.iwait(greenlet_to_host.keys()):
host = greenlet_to_host[greenlet]
progress(host)
Expand Down Expand Up @@ -57,7 +57,7 @@ def disconnect_all(state: "State"):
for host in state.activated_hosts # only hosts we connected to please!
}

with progress_spinner(greenlet_to_host.values()) as progress:
with progress_spinner(greenlet_to_host.values(), prefix_message="Disconnecting") as progress:
for greenlet in gevent.iwait(greenlet_to_host.keys()):
host = greenlet_to_host[greenlet]
progress(host)
Expand Down
7 changes: 6 additions & 1 deletion src/pyinfra/api/facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,12 @@ def get_host_fact(host, *args, **kwargs):

results = {}

with progress_spinner(greenlet_to_host.values()) as progress:
fact_cls = args[0] if args else None
fact_name = getattr(fact_cls, "name", None) or getattr(fact_cls, "__name__", "facts")

with progress_spinner(
greenlet_to_host.values(), prefix_message=f"Gathering {fact_name}"
) as progress:
for greenlet in gevent.iwait(greenlet_to_host.keys()):
host = greenlet_to_host[greenlet]
results[host] = greenlet.get()
Expand Down
17 changes: 14 additions & 3 deletions src/pyinfra/api/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,15 +198,26 @@ def host_data(self):
def group_data(self):
return self.inventory.get_groups_data(self.groups)

def _styled_name(self, *args, **kwargs) -> str:
# Dim any "@connector/" prefix so the host name stands out.
name = self.name
if name.startswith("@") and "/" in name:
connector, _, rest = name.partition("/")
return (
f"{format_text(f'{connector}/', 'bright_black')}"
f"{format_text(rest, *args, **kwargs)}"
)
return format_text(name, *args, **kwargs)

@property
def print_prefix(self) -> str:
if self.nested_executing_op_hash:
return f"{format_text('')}[{format_text(self.name, bold=True)}] {format_text('nested', 'blue')}{self.print_prefix_padding} "
return f"{self._styled_name('cyan', bold=True)} {format_text('nested', 'blue')}{self.print_prefix_padding} "

return f"{format_text('')}[{format_text(self.name, bold=True)}]{self.print_prefix_padding} "
return f"{self._styled_name('cyan', bold=True)}{self.print_prefix_padding} "

def style_print_prefix(self, *args, **kwargs) -> str:
return f"{format_text('')}[{format_text(self.name, *args, **kwargs)}]{self.print_prefix_padding} "
return f"{self._styled_name(*args, **kwargs)}{self.print_prefix_padding} "

def log(self, message: str, log_func: Callable[[str], Any] = logger.info) -> None:
log_func(f"{self.print_prefix}{message}")
Expand Down
19 changes: 13 additions & 6 deletions src/pyinfra/api/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def run_host_op(state: State, host: Host, op_hash: str) -> bool:

if op_hash not in state.ops[host]:
logger.info(f"{host.print_prefix}{format_text('Skipped', 'blue')}")
state.trigger_callbacks("operation_host_skipped", host, op_hash)
return True

op_meta = state.get_op_meta(op_hash)
Expand Down Expand Up @@ -283,7 +284,7 @@ def _run_serial_ops(state: State):

for host in list(state.inventory.get_active_hosts()):
host_operations = product([host], state.get_op_order())
with progress_spinner(host_operations) as progress:
with progress_spinner(host_operations, prefix_message=f"Running ({host.name})") as progress:
try:
_run_host_ops(
state,
Expand All @@ -300,7 +301,7 @@ def _run_no_wait_ops(state: State):
"""

hosts_operations = product(state.inventory.get_active_hosts(), state.get_op_order())
with progress_spinner(hosts_operations) as progress:
with progress_spinner(hosts_operations, prefix_message="Running operations") as progress:
# Spawn greenlet for each host to run *all* ops
if state.pool is None:
raise PyinfraError("No pool found on state.")
Expand All @@ -326,10 +327,14 @@ def _run_single_op(state: State, op_hash: str):
op_meta = state.get_op_meta(op_hash)
log_operation_start(op_meta)

op_name = ", ".join(op_meta.names) if op_meta.names else "operation"

failed_hosts = set()

if op_meta.global_arguments["_serial"]:
with progress_spinner(state.inventory.get_active_hosts()) as progress:
with progress_spinner(
state.inventory.get_active_hosts(), prefix_message=op_name
) as progress:
# For each host, run the op
for host in state.inventory.get_active_hosts():
result = _run_host_op_with_context(state, host, op_hash)
Expand All @@ -349,7 +354,7 @@ def _run_single_op(state: State, op_hash: str):
batches = [hosts[i : i + parallel] for i in range(0, len(hosts), parallel)]

for batch in batches:
with progress_spinner(batch) as progress:
with progress_spinner(batch, prefix_message=op_name) as progress:
# Spawn greenlet for each host
if state.pool is None:
raise PyinfraError("No pool found on state.")
Expand All @@ -368,11 +373,13 @@ def _run_single_op(state: State, op_hash: str):
if not greenlet.get():
failed_hosts.add(host)

# Signal the operation end first so progress handlers can finalise its
# display before fail_hosts potentially prompts or raises.
state.trigger_callbacks("operation_end", op_hash)

# Now all the batches/hosts are complete, fail any failures
state.fail_hosts(failed_hosts)

state.trigger_callbacks("operation_end", op_hash)


def run_ops(state: State, serial: bool = False, no_wait: bool = False):
"""
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
4 changes: 4 additions & 0 deletions src/pyinfra/api/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ def operation_start(state: State, op_hash):
def operation_host_start(state: State, host: Host, op_hash):
pass

@staticmethod
def operation_host_skipped(state: State, host: Host, op_hash):
pass

@staticmethod
def operation_host_success(state: State, host: Host, op_hash, retry_count: int = 0):
pass
Expand Down
2 changes: 1 addition & 1 deletion src/pyinfra/api/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ def print_host_combined_output(host: Host, output: CommandOutput) -> None:


def log_operation_start(
op_meta: StateOperationMeta, op_types: list | None = None, prefix: str = "--> "
op_meta: StateOperationMeta, op_types: list | None = None, prefix: str = ""
) -> None:
op_types = op_types or []
if op_meta.global_arguments["_serial"]:
Expand Down
Loading
Loading