diff --git a/docs/cli.md b/docs/cli.md index 553f641e9..5b54aa570 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -222,14 +222,17 @@ 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 can install shell completion for you: -+ **bash** `source scripts/pyinfra-complete.sh`. -+ **zsh** `source scripts/pyinfra-complete.zsh`. +``` +pyinfra --install-completion +``` + +This auto-detects your current shell (`bash`, `zsh` and `fish` are supported), then generates and installs the completion script to the shell-specific default location. After installation you may need to restart your shell or source your shell configuration file. -These files were generated using these commands: +You can also target a specific shell or output path: ``` -env _PYINFRA_COMPLETE=bash_source pyinfra > pyinfra-complete.sh -env _PYINFRA_COMPLETE=zsh_source pyinfra > pyinfra-complete.zsh +pyinfra --install-completion --shell zsh +pyinfra --install-completion --shell bash --output ~/.pyinfra-complete.bash ``` diff --git a/pyproject.toml b/pyproject.toml index 0bac86e3b..5f9905985 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ 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", "jinja2>3,<4", "python-dateutil>2,<3", "typeguard>=4,<5", diff --git a/scripts/pyinfra-complete.sh b/scripts/pyinfra-complete.sh deleted file mode 100644 index d073dbf38..000000000 --- a/scripts/pyinfra-complete.sh +++ /dev/null @@ -1,21 +0,0 @@ -_pyinfra_completion() { - local IFS=$' -' - COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \ - COMP_CWORD=$COMP_CWORD \ - _PYINFRA_COMPLETE=complete $1 ) ) - return 0 -} - -_pyinfra_completionetup() { - local COMPLETION_OPTIONS="" - local BASH_VERSION_ARR=(${BASH_VERSION//./ }) - # Only BASH version 4.4 and later have the nosort option. - if [ ${BASH_VERSION_ARR[0]} -gt 4 ] || ([ ${BASH_VERSION_ARR[0]} -eq 4 ] && [ ${BASH_VERSION_ARR[1]} -ge 4 ]); then - COMPLETION_OPTIONS="-o nosort" - fi - - complete $COMPLETION_OPTIONS -F _pyinfra_completion pyinfra -} - -_pyinfra_completionetup; diff --git a/scripts/pyinfra-complete.zsh b/scripts/pyinfra-complete.zsh deleted file mode 100644 index 8f49b19f6..000000000 --- a/scripts/pyinfra-complete.zsh +++ /dev/null @@ -1,28 +0,0 @@ -_pyinfra_completion() { - local -a completions - local -a completions_with_descriptions - local -a response - response=("${(@f)$( env COMP_WORDS="${words[*]}" \ - COMP_CWORD=$((CURRENT-1)) \ - _PYINFRA_COMPLETE="complete_zsh" \ - pyinfra )}") - - for key descr in ${(kv)response}; do - if [[ "$descr" == "_" ]]; then - completions+=("$key") - else - completions_with_descriptions+=("$key":"$descr") - fi - done - - if [ -n "$completions_with_descriptions" ]; then - _describe -V unsorted completions_with_descriptions -U -Q - fi - - if [ -n "$completions" ]; then - compadd -U -V unsorted -Q -a completions - fi - compstate[insert]="automenu" -} - -compdef _pyinfra_completion pyinfra; diff --git a/src/pyinfra_cli/cli.py b/src/pyinfra_cli/cli.py index 67321a09f..7789adddc 100644 --- a/src/pyinfra_cli/cli.py +++ b/src/pyinfra_cli/cli.py @@ -1,14 +1,16 @@ import logging +import os.path import sys import warnings from fnmatch import fnmatch from getpass import getpass -import os.path +from typing import Annotated +from collections.abc import Iterable from os import chdir as os_chdir, environ, getcwd from pathlib import Path -from collections.abc import Iterable import click +from cyclopts import App, Group, Parameter from pyinfra import __version__, logger, state from pyinfra.api import Config, Host, Inventory, State @@ -21,6 +23,8 @@ from pyinfra.context import ctx_config, ctx_inventory, ctx_state from pyinfra.operations import server +from pyinfra.api.output import format_text + from .commands import get_facts_and_args, get_func_and_args from .exceptions import CliError, UnexpectedExternalError, UnexpectedInternalError, WrappedError from .inventory import make_inventory @@ -40,258 +44,292 @@ from .util import exec_file, load_deploy_file, load_func, parse_cli_arg from .virtualenv import init_virtualenv +# A repeatable flag: each occurrence appends a ``True``; the count is the verbosity level. +CountFlag = Annotated[list[bool], Parameter(negative="")] -def _exit() -> None: - if ctx_state.isset() and state.failed_hosts: - sys.exit(1) - sys.exit(0) +_FALSY_BOOL_VALUES = ("", "0", "n", "no", "off", "false", "f") +_TRUTHY_BOOL_VALUES = ("1", "y", "yes", "on", "true", "t") -def _print_support(ctx, param, value): - if not value: - return +def _lenient_bool(type_, tokens) -> bool: + """Lenient boolean conversion for environment variables. - logger.info("--> Support information:") + An empty value counts as unset (False) and ``on``/``off`` are accepted, + matching the legacy Click behaviour for ``PYINFRA_YES``. + """ + value = tokens[0].value.strip().lower() if tokens else "" + if value in _FALSY_BOOL_VALUES: + return False + if value in _TRUTHY_BOOL_VALUES: + return True + raise ValueError(f"invalid boolean value: {tokens[0].value!r}") + + +app = App( + name="pyinfra", + version=f"pyinfra: v{__version__}", + version_flags=["--version"], + help_flags=["-h", "--help"], +) + +# Enable ``pyinfra --install-completion`` for shell autocompletion. +app.register_install_completion_command() + + +@app.command(name="--support") +def _support_command() -> None: + """Print useful information for support and exit.""" print_support_info() - ctx.exit() -CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) +# Parameter groups for the help page (ordered top-to-bottom as declared). +# ``negative=""`` disables the auto-generated ``--no-*`` / ``--empty-*`` flags to +# match the original flag-only CLI UX. +_no_negative = Parameter(negative="") +GROUP_EXECUTION = Group.create_ordered("Execution", default_parameter=_no_negative) +GROUP_INVENTORY = Group.create_ordered("Inventory & Data", default_parameter=_no_negative) +GROUP_PRIVILEGE = Group.create_ordered("Privilege Escalation", default_parameter=_no_negative) +GROUP_SSH = Group.create_ordered("SSH Connection", default_parameter=_no_negative) +GROUP_DEBUG = Group.create_ordered("Debugging & Output", default_parameter=_no_negative) -@click.command(context_settings=CONTEXT_SETTINGS) -@click.argument("inventory", nargs=1, type=click.Path(exists=False)) -@click.argument("operations", nargs=-1, required=True, type=click.Path(exists=False)) -@click.option( - "verbosity", - "-v", - count=True, - help="Print meta (-v), input (-vv) and output (-vvv).", -) -@click.option( - "--dry", - is_flag=True, - default=False, - help="Don't execute operations on the target hosts.", -) -@click.option( - "--diff", - is_flag=True, - default=False, - help="Show the differences when changing text files and templates.", -) -@click.option( - "-y", - "--yes", - is_flag=True, - default=False, - help="Execute operations immediately on hosts without prompt or checking for changes.", - envvar="PYINFRA_YES", - show_envvar=True, -) -@click.option( - "--limit", - help="Restrict the target hosts by name and group name.", - multiple=True, -) -@click.option( - "--exclude", - help="Exclude target hosts by name and group name.", - multiple=True, -) -@click.option("--fail-percent", type=int, help="% of hosts that need to fail before exiting early.") -@click.option( - "--data", - multiple=True, - help="Override data values, format key=value.", -) -@click.option( - "--group-data", - multiple=True, - help="Paths to load additional group data from (overrides matching keys).", -) -@click.option( - "--config", - "config_filename", - help="Specify config file to use (default: config.py).", - default="config.py", -) -@click.option( - "--chdir", - help="Set the working directory before executing.", -) -# Auth args -@click.option( - "--sudo", - is_flag=True, - default=False, - help="Whether to execute operations with sudo.", -) -@click.option("--sudo-user", help="Which user to sudo when sudoing.") -@click.option( - "--same-sudo-password", - is_flag=True, - default=False, - help="All hosts have the same sudo password, so ask only once.", -) -@click.option( - "--use-sudo-password", - is_flag=True, - default=False, - help="Whether to use a password with sudo.", -) -@click.option( - "--use-sudo-login", - is_flag=True, - default=False, - help="Use a login shell when sudo-ing.", -) -@click.option("--su-user", help="Which user to su to.") -@click.option( - "--dzdo", - is_flag=True, - default=False, - help="Whether to execute operations with dzdo.", -) -@click.option("--dzdo-user", help="Which user to dzdo when using dzdo.") -@click.option("--shell-executable", help='Shell to use (ex: "sh", "cmd", "ps").') -# Operation flow args -@click.option("--parallel", type=int, help="Number of operations to run in parallel.") -@click.option( - "--no-wait", - is_flag=True, - default=False, - help="Don't wait between operations for hosts.", -) -@click.option( - "--serial", - is_flag=True, - default=False, - help="Run operations in serial, host by host.", -) -@click.option( - "--retry", - type=int, - default=0, - help="Number of times to retry failed operations.", -) -@click.option( - "--retry-delay", - type=int, - default=5, - help="Delay in seconds between retry attempts.", -) -# SSH connector args -# TODO: remove the non-ssh-prefixed variants -@click.option("--ssh-user", "--user", "ssh_user", help="SSH user to connect as.") -@click.option("--ssh-port", "--port", "ssh_port", type=int, help="SSH port to connect to.") -@click.option("--ssh-key", "--key", "ssh_key", type=click.Path(), help="SSH Private key filename.") -@click.option( - "--ssh-key-password", - "--key-password", - "ssh_key_password", - help="SSH Private key password.", -) -@click.option("--ssh-password", "--password", "ssh_password", help="SSH password.") -@click.option( - "--ssh-password-prompt", - is_flag=True, - default=False, - help="Prompt for SSH password instead of passing it on the command line.", -) -# Eager commands (pyinfra --support) -@click.option( - "--support", - is_flag=True, - is_eager=True, - callback=_print_support, - help="Print useful information for support and exit.", -) -# Debug args -@click.option( - "--debug", - is_flag=True, - default=False, - help="Print debug logs from pyinfra.", -) -@click.option( - "--debug-all", - is_flag=True, - default=False, - help="Print debug logs from all packages including pyinfra.", -) -@click.option( - "--debug-facts", - is_flag=True, - default=False, - help="Print facts after generating operations and exit.", -) -@click.option( - "--debug-operations", - is_flag=True, - default=False, - help="Print operations after generating and exit.", -) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help=( - "Emit pure JSON output on stdout (for facts, debug-inventory, " - "debug-operations, dry runs and deploy results)." - ), -) -@click.version_option( - version=__version__, - prog_name="pyinfra", - message="%(prog)s: v%(version)s", -) -def cli(*args, **kwargs): - """ - pyinfra manages the state of one or more servers. It can be used for - app/service deployment, config management and ad-hoc command execution. +def _exit() -> None: + if ctx_state.isset() and state.failed_hosts: + sys.exit(1) + sys.exit(0) - Documentation: docs.pyinfra.com - # INVENTORY +class CliCommands: + DEBUG_INVENTORY = "DEBUG_INVENTORY" + FACT = "FACT" + SHELL = "SHELL" + DEPLOY_FILES = "DEPLOY_FILES" + FUNC = "FUNC" - \b - + a file (inventory.py) - + hostname (host.net) - + Comma separated hostnames: - host-1.net,host-2.net,@local - # OPERATIONS +@app.default +def cli( + inventory: str, + *operations: str, + verbose: Annotated[CountFlag, Parameter(name="-v", group=GROUP_DEBUG)] = [], + dry: Annotated[bool, Parameter(group=GROUP_EXECUTION)] = False, + diff: Annotated[bool, Parameter(group=GROUP_DEBUG)] = False, + yes: Annotated[ + bool, + Parameter( + name=["-y", "--yes"], + env_var="PYINFRA_YES", + converter=_lenient_bool, + group=GROUP_EXECUTION, + ), + ] = False, + limit: Annotated[tuple[str, ...], Parameter(group=GROUP_INVENTORY)] = (), + exclude: Annotated[tuple[str, ...], Parameter(group=GROUP_INVENTORY)] = (), + fail_percent: Annotated[int | None, Parameter(group=GROUP_EXECUTION)] = None, + data: Annotated[tuple[str, ...], Parameter(group=GROUP_INVENTORY)] = (), + group_data: Annotated[tuple[str, ...], Parameter(group=GROUP_INVENTORY)] = (), + config_filename: Annotated[ + str, Parameter(name="--config", group=GROUP_INVENTORY) + ] = "config.py", + chdir: Annotated[str | None, Parameter(group=GROUP_INVENTORY)] = None, + sudo: Annotated[bool, Parameter(group=GROUP_PRIVILEGE)] = False, + sudo_user: Annotated[str | None, Parameter(group=GROUP_PRIVILEGE)] = None, + same_sudo_password: Annotated[bool, Parameter(group=GROUP_PRIVILEGE)] = False, + use_sudo_password: Annotated[bool, Parameter(group=GROUP_PRIVILEGE)] = False, + use_sudo_login: Annotated[bool, Parameter(group=GROUP_PRIVILEGE)] = False, + su_user: Annotated[str | None, Parameter(group=GROUP_PRIVILEGE)] = None, + dzdo: Annotated[bool, Parameter(group=GROUP_PRIVILEGE)] = False, + dzdo_user: Annotated[str | None, Parameter(group=GROUP_PRIVILEGE)] = None, + shell_executable: Annotated[str | None, Parameter(group=GROUP_EXECUTION)] = None, + parallel: Annotated[int | None, Parameter(group=GROUP_EXECUTION)] = None, + no_wait: Annotated[bool, Parameter(group=GROUP_EXECUTION)] = False, + serial: Annotated[bool, Parameter(group=GROUP_EXECUTION)] = False, + retry: Annotated[int, Parameter(group=GROUP_EXECUTION)] = 0, + retry_delay: Annotated[int, Parameter(group=GROUP_EXECUTION)] = 5, + ssh_user: Annotated[ + str | None, Parameter(name=["--ssh-user", "--user"], group=GROUP_SSH) + ] = None, + ssh_port: Annotated[ + int | None, Parameter(name=["--ssh-port", "--port"], group=GROUP_SSH) + ] = None, + ssh_key: Annotated[str | None, Parameter(name=["--ssh-key", "--key"], group=GROUP_SSH)] = None, + ssh_key_password: Annotated[ + str | None, Parameter(name=["--ssh-key-password", "--key-password"], group=GROUP_SSH) + ] = None, + ssh_password: Annotated[ + str | None, Parameter(name=["--ssh-password", "--password"], group=GROUP_SSH) + ] = None, + ssh_password_prompt: Annotated[bool, Parameter(group=GROUP_SSH)] = False, + debug: Annotated[bool, Parameter(group=GROUP_DEBUG)] = False, + debug_all: Annotated[bool, Parameter(group=GROUP_DEBUG)] = False, + debug_facts: Annotated[bool, Parameter(group=GROUP_DEBUG)] = False, + debug_operations: Annotated[bool, Parameter(group=GROUP_DEBUG)] = False, + json_output: Annotated[bool, Parameter(name="--json", group=GROUP_DEBUG)] = False, +): + """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). - \b + Examples: + + ``` # Run one or more deploys against the inventory pyinfra INVENTORY deploy_web.py [deploy_db.py]... - \b # Run a single operation against the inventory pyinfra INVENTORY server.user pyinfra home=/home/pyinfra - \b # Execute an arbitrary command against the inventory pyinfra INVENTORY exec -- echo "hello world" - \b # Run one or more facts against the inventory pyinfra INVENTORY fact server.LinuxName [server.Users]... pyinfra INVENTORY fact files.File path=/path/to/file... - \b # Debug the inventory hosts and data pyinfra INVENTORY debug-inventory + ``` + + Parameters + ---------- + inventory + Inventory file, hostname(s) or connector to target. + operations + Deploy file(s), an operation + args, `exec -- command`, `fact ...` or + `debug-inventory`. + verbose + Print meta (-v), input (-vv) and output (-vvv). + dry + Don't execute operations on the target hosts. + diff + Show the differences when changing text files and templates. + yes + Execute operations immediately without prompt or checking for changes. + limit + Restrict the target hosts by name and group name. + exclude + Exclude target hosts by name and group name. + fail_percent + % of hosts that need to fail before exiting early. + data + Override data values, format key=value. + group_data + Paths to load additional group data from (overrides matching keys). + config_filename + Specify config file to use (default: config.py). + chdir + Set the working directory before executing. + sudo + Whether to execute operations with sudo. + sudo_user + Which user to sudo when sudoing. + same_sudo_password + All hosts have the same sudo password, so ask only once. + use_sudo_password + Whether to use a password with sudo. + use_sudo_login + Use a login shell when sudo-ing. + su_user + Which user to su to. + dzdo + Whether to execute operations with dzdo. + dzdo_user + Which user to dzdo when using dzdo. + shell_executable + Shell to use (ex: "sh", "cmd", "ps"). + parallel + Number of operations to run in parallel. + no_wait + Don't wait between operations for hosts. + serial + Run operations in serial, host by host. + retry + Number of times to retry failed operations. + retry_delay + Delay in seconds between retry attempts. + ssh_user + SSH user to connect as. + ssh_port + SSH port to connect to. + ssh_key + SSH Private key filename. + ssh_key_password + SSH Private key password. + ssh_password + SSH password. + ssh_password_prompt + Prompt for SSH password instead of passing it on the command line. + debug + Print debug logs from pyinfra. + debug_all + Print debug logs from all packages including pyinfra. + debug_facts + Print facts after generating operations and exit. + debug_operations + Print operations after generating and exit. + json_output + Emit pure JSON output on stdout (for facts, debug-inventory, + debug-operations, dry runs and deploy results). """ + if not operations: + raise CliError( + "No operations provided.\n\n" + " Operation usage:\n" + " pyinfra INVENTORY deploy_web.py [deploy_db.py]...\n" + " pyinfra INVENTORY server.user pyinfra home=/home/pyinfra\n" + ' pyinfra INVENTORY exec -- echo "hello world"\n' + " pyinfra INVENTORY fact server.LinuxName [server.Users]..." + ) try: - _main(*args, **kwargs) + _main( + inventory=inventory, + operations=list(operations), + verbosity=len(verbose), + chdir=chdir, + ssh_user=ssh_user, + ssh_port=ssh_port, + ssh_key=ssh_key, + ssh_key_password=ssh_key_password, + ssh_password=ssh_password, + ssh_password_prompt=ssh_password_prompt, + same_sudo_password=same_sudo_password, + shell_executable=shell_executable, + sudo=sudo, + sudo_user=sudo_user, + use_sudo_password=use_sudo_password, + use_sudo_login=use_sudo_login, + su_user=su_user, + dzdo=dzdo, + dzdo_user=dzdo_user, + parallel=parallel, + fail_percent=fail_percent, + data=data, + group_data=group_data, + config_filename=config_filename, + dry=dry, + diff=diff, + yes=yes, + limit=limit, + exclude=exclude, + no_wait=no_wait, + serial=serial, + retry=retry, + retry_delay=retry_delay, + debug=debug, + debug_all=debug_all, + debug_facts=debug_facts, + debug_operations=debug_operations, + json_output=json_output, + ) except (CliError, UnexpectedExternalError): raise except PyinfraError as e: - # Re-raise "expected" pyinfra exceptions with our click exception wrapper + # Re-raise "expected" pyinfra exceptions with our exception wrapper raise WrappedError(e) except Exception as e: # Re-raise any unexpected internal exceptions as UnexpectedInternalError @@ -303,36 +341,28 @@ def cli(*args, **kwargs): disconnect_all(state) -class CliCommands: - DEBUG_INVENTORY = "DEBUG_INVENTORY" - FACT = "FACT" - SHELL = "SHELL" - DEPLOY_FILES = "DEPLOY_FILES" - FUNC = "FUNC" - - def _main( inventory, operations: list | tuple, verbosity: int, - chdir: str, - ssh_user, - ssh_port: int, - ssh_key, - ssh_key_password: str, - ssh_password: str, + chdir: str | None, + ssh_user: str | None, + ssh_port: int | None, + ssh_key: str | None, + ssh_key_password: str | None, + ssh_password: str | None, ssh_password_prompt: bool, same_sudo_password: bool, - shell_executable, + shell_executable: str | None, sudo: bool, - sudo_user: str, + sudo_user: str | None, use_sudo_password: bool, use_sudo_login: bool, - su_user: str, + su_user: str | None, dzdo: bool, - dzdo_user: str, - parallel: int, - fail_percent: int, + dzdo_user: str | None, + parallel: int | None, + fail_percent: int | None, data, group_data, config_filename: str, @@ -350,7 +380,6 @@ def _main( debug_facts: bool, debug_operations: bool, json_output: bool = False, - support: bool = False, ): # In JSON mode keep the spinner quiet so stdout stays pure JSON. Do not # force --yes: a JSON run must be able to diff a host without mutating @@ -884,7 +913,7 @@ def _prepare_deploy_operations(state, config, operations): for i, filename in enumerate(operations): config.lock_current_state() - _log_styled_msg = click.style(filename, bold=True) + _log_styled_msg = format_text(filename, bold=True) logger.info(f"Loading: {_log_styled_msg}") state.current_op_file_number = i diff --git a/src/pyinfra_cli/main.py b/src/pyinfra_cli/main.py index 1f0e8e84e..289fbb228 100644 --- a/src/pyinfra_cli/main.py +++ b/src/pyinfra_cli/main.py @@ -7,7 +7,7 @@ import pyinfra from pyinfra.api.output import set_echo, set_formatter -from .cli import cli +from .cli import app def main(): @@ -42,4 +42,9 @@ def _handle_interrupt(signum, frame): gevent.signal(signal.SIGINT, gevent.kill) signal.signal(signal.SIGINT, _handle_interrupt) # print the message and exit main - cli() + + try: + app() + except click.ClickException as e: + e.show() + sys.exit(e.exit_code) diff --git a/tests/test_cli/test_cli_exceptions.py b/tests/test_cli/test_cli_exceptions.py index b56fb6402..18dab5eb1 100644 --- a/tests/test_cli/test_cli_exceptions.py +++ b/tests/test_cli/test_cli_exceptions.py @@ -3,23 +3,17 @@ from unittest import TestCase import pytest -from click.testing import CliRunner from pyinfra.api import OperationError from pyinfra.api.exceptions import ArgumentTypeError from pyinfra_cli.exceptions import CliError, UnexpectedExternalError, WrappedError -from pyinfra_cli.main import cli from .util import run_cli class TestCliExceptions(TestCase): - @classmethod - def setUpClass(cls): - cls.runner = CliRunner() - def assert_cli_exception(self, args, message): - result = self.runner.invoke(cli, args, standalone_mode=False) + result = run_cli(*args) self.assertIsInstance(result.exception, CliError) assert getattr(result.exception, "message") == message diff --git a/tests/test_cli/util.py b/tests/test_cli/util.py index 8d3377d5c..e27c500e0 100644 --- a/tests/test_cli/util.py +++ b/tests/test_cli/util.py @@ -1,16 +1,57 @@ +import contextlib +from io import StringIO from os import chdir, getcwd -from click.testing import CliRunner +import click import pyinfra -from pyinfra_cli.main import cli +from pyinfra_cli.cli import app + + +class CliResult: + """Mimics the ``click.testing.Result`` interface used across the CLI tests.""" + + def __init__(self, exit_code, stdout, stderr, exception): + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + self.output = stdout + self.exception = exception def run_cli(*arguments): cwd = getcwd() pyinfra.is_cli = True - runner = CliRunner() - result = runner.invoke(cli, arguments, standalone_mode=False) - pyinfra.is_cli = False - chdir(cwd) - return result + + stdout_buffer = StringIO() + stderr_buffer = StringIO() + + 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 + 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 + exception = e + exit_code = 1 + finally: + pyinfra.is_cli = False + chdir(cwd) + + return CliResult( + exit_code=exit_code, + stdout=stdout_buffer.getvalue(), + stderr=stderr_buffer.getvalue(), + exception=exception, + ) diff --git a/uv.lock b/uv.lock index 730ef070c..e00027530 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "bcrypt" version = "5.0.0" @@ -485,6 +494,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/b5/c5e179772ec38adb1c072b3aa13937d2860509ba32b2462bf1dda153833b/cryptography-46.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c4b93af7920cdf80f71650769464ccf1fb49a4b56ae0024173c24c48eb6b1612", size = 3438518, upload-time = "2025-10-01T00:29:06.139Z" }, ] +[[package]] +name = "cyclopts" +version = "4.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/98/ca72a91d5c25a2ae1baf19d27b31f12288cb9d8a3168b65b3cd54d40d277/cyclopts-4.22.2.tar.gz", hash = "sha256:0721e90e7209885e78f7637cfba255c12e89206b633e35d185305e349ba20ecd", size = 194511, upload-time = "2026-07-24T20:53:08.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/a11bfb5045e58b4ef69337b848985918e829fe9a90572a761d17c1a992f5/cyclopts-4.22.2-py3-none-any.whl", hash = "sha256:9c2cdf6a621886cd0af631a67437eb7d0084f33f9e8fba2d2562a1aecf75f2ff", size = 233906, upload-time = "2026-07-24T20:53:07.064Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -512,6 +538,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -823,6 +858,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -920,6 +967,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -1334,6 +1390,7 @@ name = "pyinfra" source = { editable = "." } dependencies = [ { name = "click" }, + { name = "cyclopts" }, { name = "distro" }, { name = "gevent" }, { name = "jinja2" }, @@ -1395,6 +1452,7 @@ 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" }, { name = "jinja2", specifier = ">3,<4" }, @@ -1662,6 +1720,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, +] + [[package]] name = "ruff" version = "0.14.0"