diff --git a/pyproject.toml b/pyproject.toml index f09a7f1f0..ba207a446 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ requires-python = ">=3.10,<4.0" dependencies = [ "gevent>=1.5", "paramiko>=2.7,<4", # 2.7 (2019) adds OpenSSH key format + Match SSH config + "parallel-ssh>=2.0,<3", "click>2", "jinja2>3,<4", "python-dateutil>2,<3", @@ -89,6 +90,7 @@ docker = "pyinfra.connectors.docker:DockerConnector" podman = "pyinfra.connectors.docker:PodmanConnector" local = "pyinfra.connectors.local:LocalConnector" ssh = "pyinfra.connectors.ssh:SSHConnector" +pssh = "pyinfra.connectors.pssh:PSSHConnector" dockerssh = "pyinfra.connectors.dockerssh:DockerSSHConnector" # Inventory only connectors terraform = "pyinfra.connectors.terraform:TerraformInventoryConnector" diff --git a/src/pyinfra/connectors/pssh.py b/src/pyinfra/connectors/pssh.py new file mode 100644 index 000000000..1f3141450 --- /dev/null +++ b/src/pyinfra/connectors/pssh.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +import os +import tempfile +from random import uniform +from socket import error as socket_error, gaierror +from time import sleep +from typing import IO, TYPE_CHECKING, Any, Optional, Tuple + +import click +from pssh.clients import SSHClient +from pssh.exceptions import AuthenticationException, ConnectionErrorException, SessionError, Timeout +from typing_extensions import TypedDict, Unpack, override + +from pyinfra import logger +from pyinfra.api.command import QuoteString, StringCommand +from pyinfra.api.exceptions import ConnectError + +from .base import BaseConnector, DataMeta +from .ssh_util import raise_connect_error +from .util import ( + CommandOutput, + OutputLine, + execute_command_with_sudo_retry, + make_unix_command_for_host, +) + +if TYPE_CHECKING: + from pyinfra.api.arguments import ConnectorArguments + + +class ConnectorData(TypedDict): + ssh_hostname: str + ssh_port: int + ssh_user: str + ssh_password: str + ssh_key: str + ssh_key_password: str + + ssh_allow_agent: bool + ssh_forward_agent: bool + + ssh_connect_retries: int + ssh_connect_retry_min_delay: float + ssh_connect_retry_max_delay: float + + +connector_data_meta: dict[str, DataMeta] = { + "ssh_hostname": DataMeta("SSH hostname"), + "ssh_port": DataMeta("SSH port"), + "ssh_user": DataMeta("SSH user"), + "ssh_password": DataMeta("SSH password"), + "ssh_key": DataMeta("SSH key filename"), + "ssh_key_password": DataMeta("SSH key password"), + "ssh_allow_agent": DataMeta( + "Whether to use any active SSH agent", + True, + ), + "ssh_forward_agent": DataMeta( + "Whether to enable SSH forward agent", + False, + ), + "ssh_connect_retries": DataMeta("Number of tries to connect via ssh", 0), + "ssh_connect_retry_min_delay": DataMeta( + "Lower bound for random delay between retries", + 0.1, + ), + "ssh_connect_retry_max_delay": DataMeta( + "Upper bound for random delay between retries", + 0.5, + ), +} + + +class PSSHConnector(BaseConnector): + """ + Connect to hosts over SSH using parallel-ssh library. This connector provides + an alternative to the paramiko-based SSH connector with potentially better + performance characteristics. + + .. code:: shell + + pyinfra @pssh/my-host.net ... + """ + + __examples_doc__ = """ + An inventory file (``inventory.py``) containing a single SSH target with SSH + forward agent enabled: + + .. code:: python + + hosts = [ + ("@pssh/my-host.net", {"ssh_forward_agent": True}), + ] + + Multiple hosts sharing the same SSH username: + + .. code:: python + + hosts = ( + ["@pssh/my-host-1.net", "@pssh/my-host-2.net"], + {"ssh_user": "ssh-user"}, + ) + """ + + handles_execution = True + + data_cls = ConnectorData + data_meta = connector_data_meta + data: ConnectorData + + client: Optional[SSHClient] = None + + @override + @staticmethod + def make_names_data(name): + yield "@pssh/{0}".format(name), {"ssh_hostname": name}, [] + + def make_pssh_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "host": self.data["ssh_hostname"] or self.host.name, + "allow_agent": self.data["ssh_allow_agent"], + } + + # Add user if specified + if self.data["ssh_user"]: + kwargs["user"] = self.data["ssh_user"] + + # Add port if specified + if self.data["ssh_port"]: + kwargs["port"] = int(self.data["ssh_port"]) + + # Add timeout from config + if self.state.config.CONNECT_TIMEOUT: + kwargs["timeout"] = self.state.config.CONNECT_TIMEOUT + + # Password authentication + ssh_password = self.data["ssh_password"] + if ssh_password: + kwargs["password"] = ssh_password + + # Key authentication + ssh_key = self.data["ssh_key"] + if ssh_key: + kwargs["pkey"] = ssh_key + + # Key password + ssh_key_password = self.data["ssh_key_password"] + if ssh_key_password: + kwargs["password"] = ssh_key_password + + return kwargs + + @override + def connect(self) -> None: + retries = self.data["ssh_connect_retries"] + + try: + while True: + try: + return self._connect() + except (SessionError, ConnectionErrorException, gaierror, socket_error, EOFError): + if retries == 0: + raise + retries -= 1 + min_delay = self.data["ssh_connect_retry_min_delay"] + max_delay = self.data["ssh_connect_retry_max_delay"] + sleep(uniform(min_delay, max_delay)) + except AuthenticationException as e: + raise_connect_error(self.host, "SSH authentication error", e) + except SessionError as e: + raise_connect_error(self.host, "SSH session error", e) + except ConnectionErrorException as e: + raise_connect_error(self.host, "SSH connection error", e) + except gaierror as e: + raise_connect_error(self.host, "Could not resolve hostname", e) + except socket_error as e: + raise_connect_error(self.host, "Could not connect", e) + except EOFError as e: + raise_connect_error(self.host, "EOF error", e) + + def _connect(self) -> None: + """ + Connect to a single host using parallel-ssh. + """ + kwargs = self.make_pssh_kwargs() + hostname = kwargs["host"] + logger.debug("Connecting to: %s (%r)", hostname, kwargs) + + # Don't catch retry-able exceptions, let them bubble up to connect() + self.client = SSHClient(**kwargs) + + @override + def disconnect(self) -> None: + if self.client: + try: + self.client.disconnect() + except Exception: + pass + self.client = None + + @override + def run_shell_command( + self, + command: StringCommand, + print_output: bool = False, + print_input: bool = False, + **arguments: Unpack["ConnectorArguments"], + ) -> Tuple[bool, CommandOutput]: + """ + Execute a command on the specified host using parallel-ssh. + """ + _get_pty = arguments.pop("_get_pty", False) + _timeout = arguments.pop("_timeout", None) + _stdin = arguments.pop("_stdin", None) + _success_exit_codes = arguments.pop("_success_exit_codes", None) + + def execute_command() -> Tuple[int, CommandOutput]: + unix_command = make_unix_command_for_host(self.state, self.host, command, **arguments) + actual_command = unix_command.get_raw_value() + + logger.debug( + "Running command on %s: (pty=%s) %s", + self.host.name, + _get_pty, + unix_command, + ) + + if print_input: + click.echo("{0}>>> {1}".format(self.host.print_prefix, unix_command), err=True) + + assert self.client is not None + + try: + # Run the command + host_out = self.client.run_command( + actual_command, + use_pty=_get_pty, + timeout=_timeout, + ) + + # Collect stdout + stdout_lines = [] + try: + for line in host_out.stdout: + if isinstance(line, bytes): + line = line.decode("utf-8", errors="replace") + stdout_lines.append(line.rstrip("\n")) + if print_output: + click.echo( + "{0}{1}".format(self.host.print_prefix, line.rstrip("\n")), + err=True, + ) + except Timeout: + logger.warning("Timeout reading stdout") + + # Collect stderr + stderr_lines = [] + try: + for line in host_out.stderr: + if isinstance(line, bytes): + line = line.decode("utf-8", errors="replace") + stderr_lines.append(line.rstrip("\n")) + if print_output: + click.echo( + "{0}{1}".format(self.host.print_prefix, line.rstrip("\n")), + err=True, + ) + except Timeout: + logger.warning("Timeout reading stderr") + + # Get exit code + exit_status = host_out.exit_code if host_out.exit_code is not None else -1 + + logger.debug("Command exit status: %i", exit_status) + + # Build combined output + combined_lines = [] + for line in stdout_lines: + combined_lines.append(OutputLine(buffer_name="stdout", line=line)) + for line in stderr_lines: + combined_lines.append(OutputLine(buffer_name="stderr", line=line)) + + combined_output = CommandOutput(combined_lines=combined_lines) + + return exit_status, combined_output + + except Timeout as e: + raise ConnectError("Command timeout: {0}".format(e)) from e + except Exception as e: + raise ConnectError("Command execution failed: {0}".format(e)) from e + + return_code, combined_output = execute_command_with_sudo_retry( + self.host, + arguments, + execute_command, + ) + + if _success_exit_codes: + status = return_code in _success_exit_codes + else: + status = return_code == 0 + + return status, combined_output + + @override + def get_file( + self, + remote_filename: str, + filename_or_io, + remote_temp_filename=None, + print_output: bool = False, + print_input: bool = False, + **arguments: Unpack["ConnectorArguments"], + ) -> bool: + """ + Download a file from the remote host using SFTP. + """ + _sudo = arguments.get("_sudo", False) + _su_user = arguments.get("_su_user", None) + + if _sudo or _su_user: + # Get temp file location + temp_file = remote_temp_filename or self.host.get_temp_filename(remote_filename) + + # Copy the file to the tempfile location and add read permissions + command = StringCommand( + "cp", remote_filename, temp_file, "&&", "chmod", "+r", temp_file + ) + + copy_status, output = self.run_shell_command( + command, + print_output=print_output, + print_input=print_input, + **arguments, + ) + + if copy_status is False: + logger.error("File download copy temp error: {0}".format(output.stderr)) + return False + + try: + self._get_file(temp_file, filename_or_io) + finally: + remove_status, output = self.run_shell_command( + StringCommand("rm", "-f", temp_file), + print_output=print_output, + print_input=print_input, + **arguments, + ) + + if remove_status is False: + logger.error("File download remove temp error: {0}".format(output.stderr)) + return False + else: + self._get_file(remote_filename, filename_or_io) + + if print_output: + click.echo( + "{0}file downloaded: {1}".format(self.host.print_prefix, remote_filename), + err=True, + ) + + return True + + def _get_file(self, remote_filename: str, filename_or_io: str | IO): + """ + Internal method to download a file using SFTP. + """ + assert self.client is not None + + try: + # If filename_or_io is a string (file path), use it directly + if isinstance(filename_or_io, str): + self.client.copy_remote_file(remote_filename, filename_or_io) + else: + # If it's a file-like object, we need to download to a temp file first + # then copy the contents to the file-like object + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + tmp_path = tmp_file.name + + try: + self.client.copy_remote_file(remote_filename, tmp_path) + with open(tmp_path, "rb") as src: + filename_or_io.write(src.read()) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception as e: + raise ConnectError("Failed to download file: {0}".format(e)) from e + + @override + def put_file( + self, + filename_or_io, + remote_filename, + remote_temp_filename=None, + print_output: bool = False, + print_input: bool = False, + **arguments: Unpack["ConnectorArguments"], + ) -> bool: + """ + Upload a file to the remote host using SFTP. + """ + original_arguments = arguments.copy() + + _sudo = arguments.pop("_sudo", False) + _sudo_user = arguments.pop("_sudo_user", False) + _doas = arguments.pop("_doas", False) + _doas_user = arguments.pop("_doas_user", False) + _su_user = arguments.pop("_su_user", None) + + if _sudo or _doas or _su_user: + # Get temp file location + temp_file = remote_temp_filename or self.host.get_temp_filename(remote_filename) + self._put_file(filename_or_io, temp_file) + + # Make sure our sudo/su user can access the file + other_user = _su_user or _sudo_user or _doas_user + if other_user: + status, output = self.run_shell_command( + StringCommand("setfacl", "-m", f"u:{other_user}:r", temp_file), + print_output=print_output, + print_input=print_input, + **arguments, + ) + + if status is False: + logger.error("Error on handover to sudo/su user: {0}".format(output.stderr)) + return False + + # Copy to final location + command = StringCommand("cp", temp_file, QuoteString(remote_filename)) + + status, output = self.run_shell_command( + command, + print_output=print_output, + print_input=print_input, + **original_arguments, + ) + + if status is False: + logger.error("File upload error: {0}".format(output.stderr)) + return False + + # Delete the temporary file + command = StringCommand("rm", "-f", temp_file) + + status, output = self.run_shell_command( + command, + print_output=print_output, + print_input=print_input, + **arguments, + ) + + if status is False: + logger.error("Unable to remove temporary file: {0}".format(output.stderr)) + return False + else: + self._put_file(filename_or_io, remote_filename) + + if print_output: + click.echo( + "{0}file uploaded: {1}".format(self.host.print_prefix, remote_filename), + err=True, + ) + + return True + + def _put_file(self, filename_or_io, remote_location): + """ + Internal method to upload a file using SFTP. + """ + logger.debug("Attempting upload of %s to %s", filename_or_io, remote_location) + + assert self.client is not None + + try: + # If filename_or_io is a string (file path), use it directly + if isinstance(filename_or_io, str): + self.client.copy_file(filename_or_io, remote_location) + else: + # If it's a file-like object, we need to write it to a temp file first + # then upload the temp file + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + tmp_path = tmp_file.name + # Copy contents from file-like object to temp file + filename_or_io.seek(0) + tmp_file.write(filename_or_io.read()) + + try: + self.client.copy_file(tmp_path, remote_location) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception as e: + raise ConnectError("Failed to upload file: {0}".format(e)) from e diff --git a/tests/test_connectors/test_pssh.py b/tests/test_connectors/test_pssh.py new file mode 100644 index 000000000..28df20c9e --- /dev/null +++ b/tests/test_connectors/test_pssh.py @@ -0,0 +1,760 @@ +# encoding: utf-8 + +from socket import error as socket_error, gaierror +from unittest import TestCase, mock + +from pssh.exceptions import AuthenticationException, ConnectionErrorException, SessionError, Timeout + +from pyinfra.api import Config, MaskString, State, StringCommand +from pyinfra.api.connect import connect_all +from pyinfra.api.exceptions import ConnectError, PyinfraError +from pyinfra.context import ctx_state + +from ..util import make_inventory + + +def make_raise_exception_function(cls, *args, **kwargs): + def handler(*a, **kw): + raise cls(*args, **kwargs) + + return handler + + +class TestPSSHConnector(TestCase): + def setUp(self): + self.fake_ssh_client_patch = mock.patch("pyinfra.connectors.pssh.SSHClient") + self.fake_ssh_client_mock = self.fake_ssh_client_patch.start() + + def tearDown(self): + self.fake_ssh_client_patch.stop() + + def test_connect_all(self): + inventory = make_inventory(hosts=(("@pssh/somehost", {}), ("@pssh/anotherhost", {}))) + state = State(inventory, Config()) + connect_all(state) + assert len(state.active_hosts) == 2 + + def test_connect_host(self): + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect(reason=True) + assert len(state.active_hosts) == 0 + + def test_connect_all_password(self): + inventory = make_inventory( + hosts=(("@pssh/somehost", {}), ("@pssh/anotherhost", {})), + override_data={"ssh_password": "test"}, + ) + + # Get a host + somehost = inventory.get_host("@pssh/somehost") + assert somehost.data.ssh_password == "test" + + state = State(inventory, Config()) + connect_all(state) + + assert len(state.active_hosts) == 2 + + def test_connect_exceptions(self): + for exception_class in ( + AuthenticationException, + ConnectionErrorException, + SessionError, + gaierror, + socket_error, + EOFError, + ): + inventory = make_inventory( + hosts=(("@pssh/somehost", {"ssh_key": "testkey"}),), + ) + state = State(inventory, Config()) + + # Mock SSHClient to raise exception on instantiation + self.fake_ssh_client_mock.side_effect = make_raise_exception_function(exception_class) + + with self.assertRaises(PyinfraError): + connect_all(state) + + assert len(state.active_hosts) == 0 + + # Reset the side effect for next iteration + self.fake_ssh_client_mock.side_effect = None + + def test_connect_with_ssh_key(self): + inventory = make_inventory(hosts=(("@pssh/somehost", {"ssh_key": "testkey"}),)) + state = State(inventory, Config()) + + fake_client = mock.MagicMock() + self.fake_ssh_client_mock.return_value = fake_client + + connect_all(state) + + # Check the SSHClient was created with the correct parameters + self.fake_ssh_client_mock.assert_called_with( + host="somehost", + allow_agent=True, + pkey="testkey", + timeout=10, + user="vagrant", + ) + + def test_connect_with_ssh_key_password(self): + inventory = make_inventory( + hosts=(("@pssh/somehost", {"ssh_key": "testkey", "ssh_key_password": "testpass"}),), + ) + state = State(inventory, Config()) + + fake_client = mock.MagicMock() + self.fake_ssh_client_mock.return_value = fake_client + + connect_all(state) + + # Check the SSHClient was created with the correct parameters + self.fake_ssh_client_mock.assert_called_with( + host="somehost", + allow_agent=True, + pkey="testkey", + password="testpass", + timeout=10, + user="vagrant", + ) + + def test_connect_with_password(self): + inventory = make_inventory( + hosts=(("@pssh/somehost", {"ssh_password": "testpass"}),), + ) + state = State(inventory, Config()) + + fake_client = mock.MagicMock() + self.fake_ssh_client_mock.return_value = fake_client + + connect_all(state) + + # Check the SSHClient was created with the correct parameters + self.fake_ssh_client_mock.assert_called_with( + host="somehost", + allow_agent=True, + password="testpass", + timeout=10, + user="vagrant", + ) + + def test_connect_with_custom_port(self): + inventory = make_inventory( + hosts=(("@pssh/somehost", {"ssh_port": 2222}),), + ) + state = State(inventory, Config()) + + fake_client = mock.MagicMock() + self.fake_ssh_client_mock.return_value = fake_client + + connect_all(state) + + # Check the SSHClient was created with the correct port + self.fake_ssh_client_mock.assert_called_with( + host="somehost", + allow_agent=True, + port=2222, + timeout=10, + user="vagrant", + ) + + # Command execution tests + + def test_run_shell_command(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter(["output line 1", "output line 2"]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 0 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + command = "echo test" + status, output = host.run_shell_command(command, print_output=True) + + assert status is True + assert len(output.stdout_lines) == 2 + assert output.stdout_lines[0] == "output line 1" + assert output.stdout_lines[1] == "output line 2" + + fake_client.run_command.assert_called_with( + "sh -c 'echo test'", + use_pty=False, + timeout=None, + ) + + def test_run_shell_command_with_unicode(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter(["Šablony"]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 0 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + command = "echo Šablony" + status, output = host.run_shell_command(command, print_output=True) + + assert status is True + fake_client.run_command.assert_called_with( + "sh -c 'echo Šablony'", + use_pty=False, + timeout=None, + ) + + @mock.patch("pyinfra.connectors.pssh.click") + def test_run_shell_command_masked(self, fake_click): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 0 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + command = StringCommand("echo", MaskString("top-secret-stuff")) + status, output = host.run_shell_command(command, print_output=True, print_input=True) + + assert status is True + + fake_client.run_command.assert_called_with( + "sh -c 'echo top-secret-stuff'", + use_pty=False, + timeout=None, + ) + + fake_click.echo.assert_called_with( + "{0}>>> sh -c 'echo ***'".format(host.print_prefix), + err=True, + ) + + def test_run_shell_command_success_exit_code(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 1 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + command = "echo hi" + status, output = host.run_shell_command(command, _success_exit_codes=[1]) + + assert status is True + + def test_run_shell_command_error(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter(["error message"]) + fake_host_out.exit_code = 1 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect(state) + + command = "echo hi" + status, output = host.run_shell_command(command) + + assert status is False + assert len(output.stderr_lines) == 1 + assert output.stderr_lines[0] == "error message" + + def test_run_shell_command_with_pty(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 0 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + command = "echo test" + status, output = host.run_shell_command(command, _get_pty=True) + + assert status is True + fake_client.run_command.assert_called_with( + "sh -c 'echo test'", + use_pty=True, + timeout=None, + ) + + def test_run_shell_command_with_timeout(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 0 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + command = "echo test" + status, output = host.run_shell_command(command, _timeout=30) + + assert status is True + fake_client.run_command.assert_called_with( + "sh -c 'echo test'", + use_pty=False, + timeout=30, + ) + + def test_run_shell_command_timeout_exception(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + + # Make stderr iteration raise Timeout + def stderr_with_timeout(): + yield "some output" + raise Timeout("Command timed out") + + fake_host_out.stdout = iter([]) + fake_host_out.stderr = stderr_with_timeout() + fake_host_out.exit_code = None + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + command = "sleep 100" + # The timeout is caught and logged as a warning, not raised as an exception + status, output = host.run_shell_command(command, _timeout=1) + # Should fail with exit code -1 + assert status is False + + @mock.patch("pyinfra.connectors.util.getpass") + def test_run_shell_command_sudo_password_automatic_prompt(self, fake_getpass): + fake_client = mock.MagicMock() + + # First call: command fails without sudo password + first_fake_host_out = mock.MagicMock() + first_fake_host_out.stdout = iter(["sudo: a password is required\r"]) + first_fake_host_out.stderr = iter([]) + first_fake_host_out.exit_code = 1 + + # Second call: create askpass script + second_fake_host_out = mock.MagicMock() + second_fake_host_out.stdout = iter(["/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX"]) + second_fake_host_out.stderr = iter([]) + second_fake_host_out.exit_code = 0 + + # Third call: command succeeds with sudo password + third_fake_host_out = mock.MagicMock() + third_fake_host_out.stdout = iter(["success"]) + third_fake_host_out.stderr = iter([]) + third_fake_host_out.exit_code = 0 + + fake_client.run_command.side_effect = [ + first_fake_host_out, + second_fake_host_out, + third_fake_host_out, + ] + + self.fake_ssh_client_mock.return_value = fake_client + fake_getpass.return_value = "password" + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + command = "echo Šablony" + status, output = host.run_shell_command(command, _sudo=True, print_output=True) + + assert status is True + assert fake_getpass.called + + # File transfer tests + + def test_put_file(self): + fake_client = mock.MagicMock() + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/anotherhost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/anotherhost") + host.connect() + + fake_open = mock.mock_open(read_data="test!") + with mock.patch("pyinfra.api.util.open", fake_open, create=True): + with ctx_state.use(state): + status = host.put_file( + "not-a-file", + "not-another-file", + print_output=True, + ) + + assert status is True + fake_client.copy_file.assert_called_once() + + def test_put_file_sudo(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 0 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/anotherhost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/anotherhost") + host.connect() + + fake_open = mock.mock_open(read_data="test!") + with mock.patch("pyinfra.api.util.open", fake_open, create=True): + with ctx_state.use(state): + status = host.put_file( + "not-a-file", + "not another file", + print_output=True, + _sudo=True, + _sudo_user="ubuntu", + ) + + assert status is True + + # Should have called copy_file for the temp file + assert fake_client.copy_file.called + + # Should have run commands to set ACL, copy, and remove temp file + assert fake_client.run_command.call_count == 3 + + def test_put_file_doas(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 0 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/anotherhost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/anotherhost") + host.connect() + + fake_open = mock.mock_open(read_data="test!") + with mock.patch("pyinfra.api.util.open", fake_open, create=True): + with ctx_state.use(state): + status = host.put_file( + "not-a-file", + "not another file", + print_output=True, + _doas=True, + _doas_user="ubuntu", + ) + + assert status is True + assert fake_client.copy_file.called + + def test_put_file_su_user_fail_acl(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter(["setfacl: Operation not permitted"]) + fake_host_out.exit_code = 1 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/anotherhost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/anotherhost") + host.connect() + + fake_open = mock.mock_open(read_data="test!") + with mock.patch("pyinfra.api.util.open", fake_open, create=True): + with ctx_state.use(state): + status = host.put_file( + "not-a-file", + "not-another-file", + print_output=True, + _su_user="centos", + ) + + assert status is False + + def test_get_file(self): + fake_client = mock.MagicMock() + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + fake_open = mock.mock_open(read_data="test!") + with mock.patch("pyinfra.api.util.open", fake_open, create=True): + with ctx_state.use(state): + status = host.get_file( + "not-a-file", + "not-another-file", + print_output=True, + ) + + assert status is True + fake_client.copy_remote_file.assert_called_once_with("not-a-file", "not-another-file") + + def test_get_file_sudo(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 0 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + fake_open = mock.mock_open(read_data="test!") + with mock.patch("pyinfra.api.util.open", fake_open, create=True): + with ctx_state.use(state): + status = host.get_file( + "not-a-file", + "not-another-file", + print_output=True, + _sudo=True, + _sudo_user="ubuntu", + ) + + assert status is True + + # Should have called copy_remote_file for the temp file + assert fake_client.copy_remote_file.called + + # Should have run commands to copy and remove temp file + assert fake_client.run_command.call_count == 2 + + def test_get_file_sudo_copy_fail(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter(["cp: cannot stat"]) + fake_host_out.exit_code = 1 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + with ctx_state.use(state): + status = host.get_file( + "not-a-file", + "not-another-file", + print_output=True, + _sudo=True, + _sudo_user="ubuntu", + ) + + assert status is False + + def test_get_file_sudo_remove_fail(self): + fake_client = mock.MagicMock() + + # First call (copy): success + first_fake_host_out = mock.MagicMock() + first_fake_host_out.stdout = iter([]) + first_fake_host_out.stderr = iter([]) + first_fake_host_out.exit_code = 0 + + # Second call (remove): fail + second_fake_host_out = mock.MagicMock() + second_fake_host_out.stdout = iter([]) + second_fake_host_out.stderr = iter(["rm: cannot remove"]) + second_fake_host_out.exit_code = 1 + + fake_client.run_command.side_effect = [first_fake_host_out, second_fake_host_out] + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + fake_open = mock.mock_open(read_data="test!") + with mock.patch("pyinfra.api.util.open", fake_open, create=True): + with ctx_state.use(state): + status = host.get_file( + "not-a-file", + "not-another-file", + print_output=True, + _sudo=True, + _sudo_user="ubuntu", + ) + + assert status is False + + def test_get_file_su_user(self): + fake_client = mock.MagicMock() + fake_host_out = mock.MagicMock() + fake_host_out.stdout = iter([]) + fake_host_out.stderr = iter([]) + fake_host_out.exit_code = 0 + fake_client.run_command.return_value = fake_host_out + + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + state = State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + fake_open = mock.mock_open(read_data="test!") + with mock.patch("pyinfra.api.util.open", fake_open, create=True): + with ctx_state.use(state): + status = host.get_file( + "not-a-file", + "not-another-file", + print_output=True, + _su_user="centos", + ) + + assert status is True + assert fake_client.copy_remote_file.called + + # Connection retry tests + + @mock.patch("pyinfra.connectors.pssh.sleep") + def test_pssh_connect_fail_retry(self, fake_sleep): + for exception_class in ( + SessionError, + ConnectionErrorException, + gaierror, + socket_error, + EOFError, + ): + fake_sleep.reset_mock() + self.fake_ssh_client_mock.reset_mock() + + inventory = make_inventory( + hosts=(("@pssh/unresponsivehost", {}),), + override_data={"ssh_connect_retries": 1}, + ) + State(inventory, Config()) + + unresponsivehost = inventory.get_host("@pssh/unresponsivehost") + assert unresponsivehost.data.ssh_connect_retries == 1 + + self.fake_ssh_client_mock.side_effect = exception_class() + + with self.assertRaises(ConnectError): + unresponsivehost.connect(show_errors=False, raise_exceptions=True) + + fake_sleep.assert_called_once() + assert self.fake_ssh_client_mock.call_count == 2 + + # Reset side effect for next iteration + self.fake_ssh_client_mock.side_effect = None + + @mock.patch("pyinfra.connectors.pssh.sleep") + def test_pssh_connect_fail_success(self, fake_sleep): + for exception_class in ( + SessionError, + ConnectionErrorException, + gaierror, + socket_error, + EOFError, + ): + fake_sleep.reset_mock() + self.fake_ssh_client_mock.reset_mock() + + inventory = make_inventory( + hosts=(("@pssh/unresponsivehost", {}),), + override_data={"ssh_connect_retries": 1}, + ) + State(inventory, Config()) + + unresponsivehost = inventory.get_host("@pssh/unresponsivehost") + assert unresponsivehost.data.ssh_connect_retries == 1 + + fake_client = mock.MagicMock() + self.fake_ssh_client_mock.side_effect = [ + exception_class(), + fake_client, + ] + + unresponsivehost.connect(show_errors=False, raise_exceptions=True) + fake_sleep.assert_called_once() + assert self.fake_ssh_client_mock.call_count == 2 + + # Reset side effect for next iteration + self.fake_ssh_client_mock.side_effect = None + + def test_disconnect(self): + fake_client = mock.MagicMock() + self.fake_ssh_client_mock.return_value = fake_client + + inventory = make_inventory(hosts=(("@pssh/somehost", {}),)) + State(inventory, Config()) + host = inventory.get_host("@pssh/somehost") + host.connect() + + # Disconnect should call disconnect on the client + host.disconnect() + fake_client.disconnect.assert_called_once() + + def test_make_names_data(self): + from pyinfra.connectors.pssh import PSSHConnector + + # Test the static method that generates inventory targets + results = list(PSSHConnector.make_names_data("testhost")) + assert len(results) == 1 + assert results[0][0] == "@pssh/testhost" + assert results[0][1] == {"ssh_hostname": "testhost"} + assert results[0][2] == [] diff --git a/uv.lock b/uv.lock index b78261f43..2b8907b7b 100644 --- a/uv.lock +++ b/uv.lock @@ -918,6 +918,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "parallel-ssh" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gevent" }, + { name = "ssh-python" }, + { name = "ssh2-python" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/98/bab96a11d57ef276fa24e297358383337bcf6a310e7df63482be6e8389a6/parallel_ssh-2.14.0.tar.gz", hash = "sha256:e4d21a02feb4cfebf58692448d71c0cd14799c40881c5bd6d75aa7d1da4fd0d5", size = 71375, upload-time = "2025-02-02T18:37:38.952Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/64/93cded9759f4acd21c85ea07ac93874fc63cf59611b05c6b273375ed6682/parallel_ssh-2.14.0-py3-none-any.whl", hash = "sha256:acfda90abe3de6d80fc8df1ac3b2a435911d49a5293282efc2d3d306f60e6093", size = 86181, upload-time = "2025-02-02T18:37:36.831Z" }, +] + [[package]] name = "paramiko" version = "3.5.1" @@ -1028,6 +1042,7 @@ dependencies = [ { name = "gevent" }, { name = "jinja2" }, { name = "packaging" }, + { name = "parallel-ssh" }, { name = "paramiko" }, { name = "python-dateutil" }, { name = "typeguard" }, @@ -1087,6 +1102,7 @@ requires-dist = [ { name = "gevent", specifier = ">=1.5" }, { name = "jinja2", specifier = ">3,<4" }, { name = "packaging", specifier = ">=16.1" }, + { name = "parallel-ssh", specifier = ">=2.0,<3" }, { name = "paramiko", specifier = ">=2.7,<4" }, { name = "python-dateutil", specifier = ">2,<3" }, { name = "typeguard", specifier = ">=4,<5" }, @@ -1478,6 +1494,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "ssh-python" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/40/99f1e26261e0e6b3470342e559f5f122c8e925f8d744514d686004bc5748/ssh_python-1.1.1.tar.gz", hash = "sha256:be44618803fcaa1c2cd0298b3fb5f3064220a8b3dd36670ac0cc08efc7ae2aaf", size = 2025560, upload-time = "2025-01-23T02:27:07.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/cc/540db8bb5656963ede4e86a0c005e57a3fbb1ac7b729b2deb27d847a0f35/ssh_python-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04947e80759ebb9475b70ff43de81b344111a4e512f78b83fcc388f115774a29", size = 3731268, upload-time = "2025-01-23T02:27:37.979Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e2/4d326648462ee7d64d045c6235a8741df2d54675beae495da90060a07ad2/ssh_python-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffb90164ff530be46b12662ae41da40878ec76798edd249a8f2ee20604e5a5d6", size = 3417107, upload-time = "2025-01-23T02:26:51.1Z" }, + { url = "https://files.pythonhosted.org/packages/c6/95/0cb6a33a3b1cb946d53cfaca72d5ff7b0422fc89c382d72a9d532f6ef556/ssh_python-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:67f7de18101a964083cea68f9efb440fa93a87d567b5ad22cb2ed0290711407f", size = 3801411, upload-time = "2025-01-23T02:36:01.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9d/8a1e281945bb43cd6aa531c4cc5a903fa715f89cca7459f9d7b06c402893/ssh_python-1.1.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:111b675b9d48e1482683b7e7347b5d0ccffacf41d07fa4e77917022997c117cd", size = 1724504, upload-time = "2025-01-23T02:26:48.445Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a7/51e6f48742cfd27dbac916d867a6746224f5e6c059f82db9aba33889d24d/ssh_python-1.1.1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:cb85749e34907b1335572f39f8cdfb8fb29065dca86736cae7b6cf2f43bb2ec3", size = 2178115, upload-time = "2025-01-23T02:28:09.314Z" }, + { url = "https://files.pythonhosted.org/packages/36/0a/ec48481e94df59d2565ce9e203198b9e2bfca97257badf27a0cb9d4897cf/ssh_python-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5256ae39786763bd2720d40b6821eeae6587a31a2806eafd55d2969294b5eaff", size = 3715100, upload-time = "2025-01-23T02:27:39.99Z" }, + { url = "https://files.pythonhosted.org/packages/92/8d/c97216d559ea42fe404982fe9e2131dde0be5e84b6a9a051b12d5cd74017/ssh_python-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:741a2c189c05e8051498ac27dcd7fe9ee4c3270e3741e2514e8be0b6be1f381d", size = 3395472, upload-time = "2025-01-23T02:26:52.662Z" }, + { url = "https://files.pythonhosted.org/packages/d4/69/3d18db2c6ecfad12ab391fe97d681330988ee983d8b63d4cf6199fb7181e/ssh_python-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:70151a8ad33ba789913d417ac0cb5503a3bc2d51de81cf87d91eeecf50d7497d", size = 3801811, upload-time = "2025-01-23T02:36:02.522Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/bb460526c8e96187077856d6f73c673136a84c74dfd68c551a072c646a3c/ssh_python-1.1.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:e9bf86fc7a8269f417664fd5eb6246fa1d07f435bf6807e2c670bd90a9ac865a", size = 2800391, upload-time = "2025-01-23T02:24:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/82/53/fd91cace8af0030146e9f4f64233ea6a557154b57f9c7ed1f463ede575d5/ssh_python-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccb363e245052ec02359310e78627158a379443ce5148750b25f805c59dfbfa", size = 3739239, upload-time = "2025-01-23T02:27:42.458Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/0fb3253045a06c66eca5454eaea432e56f3759416ba5554d239a08d9018d/ssh_python-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b44ed8a663b0d06223c3ffbe8ed54fa2d147d0574dbc94d11f601e4c309641df", size = 3421681, upload-time = "2025-01-23T02:26:54.379Z" }, + { url = "https://files.pythonhosted.org/packages/be/e5/0aa27472a88da140aef96b80eb85fb690decc380a77de122abeef7021142/ssh_python-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:0cd5bb334e983449c0e2ccd896fdb965bd42fe8c2a0e93ec34cec8603718b2fc", size = 3803504, upload-time = "2025-01-23T02:36:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ff/c1eaf21f3f0dccc07492a617e85333a24d40218fb844a701ba93c8a62907/ssh_python-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c71ca71c740305300ecc43ad2eb93666c6dc261427fbfa24c207cd8e46576ec8", size = 3725653, upload-time = "2025-01-23T02:27:43.889Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5a/9d33d94b30c9e9e231222b02681ef88572acb98949e8304cc45e23b21e0b/ssh_python-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe34b160179d926fd8924bd1abb9dda66d6920623b49c603a8743aaa8e2fff17", size = 3405663, upload-time = "2025-01-23T02:26:56Z" }, + { url = "https://files.pythonhosted.org/packages/27/98/66462ecae26f0b15e986be9cac44aa24d4d7f72c210716c61f820894d2fa/ssh_python-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:fdb756a7ca1db520e212d22a07a455dcbb96a4e5790f6c5f0a1d85b55ed7d93b", size = 3791568, upload-time = "2025-01-23T02:36:06.788Z" }, + { url = "https://files.pythonhosted.org/packages/22/2f/7cf9ed3aa21290c5923ddfd5f8a23895d97507430f37fc50e99f75af8eef/ssh_python-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be6dd112eaf5a651c3751100ec8fb78743fa56eb784943ce640a6230916d888", size = 3651474, upload-time = "2025-01-23T02:27:54.586Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/377f725ac2f798e6b96cb8c2a721b3a05e21c4096a996d48d6f604c83c85/ssh_python-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50debaa0dfd0534816ac381abea40f11b87567b019bee82c46692a763ceffbd2", size = 3335098, upload-time = "2025-01-23T02:27:05.59Z" }, +] + +[[package]] +name = "ssh2-python" +version = "1.1.2.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/14/7b1072447ffa15d1e1b099611ecd8e985f2dfa62b2ad436dee303a141f6a/ssh2_python-1.1.2.post1.tar.gz", hash = "sha256:c39b9f11fd0e2a7422480aa61b8787c444b26a402907c9e2798767763763bb7c", size = 2132633, upload-time = "2025-01-23T03:19:03.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/76/b395cca37dde897bc1e0ab7aadc98de181ace9ac19aaf236d6148c05ba03/ssh2_python-1.1.2.post1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fa9a4633bfe3e59bb6ae9c491c1fae22ee409807ad253b06b190ab7be43466f", size = 4890980, upload-time = "2025-01-23T03:18:40.195Z" }, + { url = "https://files.pythonhosted.org/packages/aa/71/470cd75e2aaa56a06107f1d1079d2fb572dadf4156200b8fd4fe832e43f0/ssh2_python-1.1.2.post1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffb00c332571a1d643f47efcad46e4559adf30ff76e0d8cad7fa4f50e01bf31e", size = 4538004, upload-time = "2025-01-23T03:18:54.402Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/b3b9473020f99f3df0e5e0e92cef8abef08a273493eea44b9d771099d246/ssh2_python-1.1.2.post1-cp310-cp310-win_amd64.whl", hash = "sha256:b1012086f8f48a215f9d646799da352431ec5ac246f284d1c00d30a45ebcdf7b", size = 4222731, upload-time = "2025-01-23T03:13:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f0/fa5de562be7130375c445561074aed6f5b214c6c45ef7f1f61a13625c2cf/ssh2_python-1.1.2.post1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:52efc9a017016d35a3bb3c45ef292bfcf2e4c7f73866fc3b39be7e41ac14d440", size = 1844981, upload-time = "2025-01-23T03:09:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/eb973a72c5c3664f0d47f83de96746e10e03fc10b9df05510d8c19d63f5f/ssh2_python-1.1.2.post1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:e388d573324254d73d410a44549cdbacb4b7fa970a421132f1d8e8ca01d42967", size = 2326239, upload-time = "2025-01-23T03:07:36.183Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/4ea6ff0d2f48a118875a11d8af98d644d6556062e3518e24ca05fae84991/ssh2_python-1.1.2.post1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:725aafe3f6423c44e33d63e38c0f0990b259664404c6b859cd19c93203bfaa3e", size = 4975617, upload-time = "2025-01-23T03:18:42.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/cd7f9b4ff2ca0539ac4c900e1d00160e97b00b77078f9a9ddb36847f0231/ssh2_python-1.1.2.post1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d821b1b556afa0d27b82de79f4a88e11dc627689dc274ca9b846c8aa79253ab5", size = 4619186, upload-time = "2025-01-23T03:18:59.293Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/a8478bf90c35d86a85dd5e13e188cea2c81fddfbc3efb16a2fec37d6bee9/ssh2_python-1.1.2.post1-cp311-cp311-win_amd64.whl", hash = "sha256:9e4aaeab5ade22324522314c6992f1a07e1d30fb109c2d3eb4eac6a15df501f3", size = 4223589, upload-time = "2025-01-23T03:13:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/91daac3eda23220ee323364bcf93dd8972a74870597d45b6fced78c1e66e/ssh2_python-1.1.2.post1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3892d56a340df0dbc4f57d2debb7e17a5b93b171af27b2b71d4b3351304863ff", size = 3049981, upload-time = "2025-01-23T03:11:56.087Z" }, + { url = "https://files.pythonhosted.org/packages/11/13/aef295d9ef0eba407864173c21f0c1961099265495721df843405e1f5bf8/ssh2_python-1.1.2.post1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:139520fd2f12cf16cbc46ccc966af06d44864fc750f31f2da0d1aafffd163057", size = 5146630, upload-time = "2025-01-23T03:18:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/78/d5/2e3105d3d6dcc23f99fb3a96dadefdf0f2edbb770489bd14c92250d07c2c/ssh2_python-1.1.2.post1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19b8c83a1e923e5b4fac591cba86a09c42ffa5d0ba21456ad911f43936001e24", size = 4801706, upload-time = "2025-01-23T03:19:02.286Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/cd685009f12243d76fe4a5249a17f5a3229989d3163c44a344007e1e36b3/ssh2_python-1.1.2.post1-cp312-cp312-win_amd64.whl", hash = "sha256:84694163693310aac0482ab131d1743b5459dfe0de26201dc20e2e6e31168bc0", size = 4221651, upload-time = "2025-01-23T03:13:10.665Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ab/0c8a64ede32a78e97dd575290a4561dd84da4e0cafc27406db4f93b368dd/ssh2_python-1.1.2.post1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3be981b8b814fcb0fbbcb1a01cd309405509a084469bd7158d67d7c13fbc273", size = 5063791, upload-time = "2025-01-23T03:18:47.129Z" }, + { url = "https://files.pythonhosted.org/packages/4e/47/a85134c2afc1aa675fdea20c310730f684ddd9f343c5e8211e53cd3e8d4e/ssh2_python-1.1.2.post1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b193f7b83f0ac62e49cc096e1eb6087a409097a71b5805ef6b588463cfd741c", size = 4719238, upload-time = "2025-01-23T03:19:05.006Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3b/12db397a7935d743b4b4c51ab7be9bc5289c3466e2eba6acd5f5bc00afa6/ssh2_python-1.1.2.post1-cp313-cp313-win_amd64.whl", hash = "sha256:431a549ee28a5cefd6facd1eec7a47e1125fb34b05628b2092eeb7799755e470", size = 4210838, upload-time = "2025-01-23T03:13:12.869Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f0/06db27d8b59a9b589065feb6a73603164cd65a0f01cf9febe343699f8025/ssh2_python-1.1.2.post1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83aa52be4ced1102ca879a2c8a8c5663661fec9751f721ee8bfeddcd11a49e9f", size = 3320277, upload-time = "2025-01-23T03:19:00.723Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ff/aba8dcd4d96d030feb5d28f0feff5aa60b95a832725d617f72900d251d2e/ssh2_python-1.1.2.post1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba3b5cb8763552118afafddd613dd0e62119313e1a799d0748dab0d3c512f9", size = 2965281, upload-time = "2025-01-23T03:19:18.824Z" }, +] + [[package]] name = "stack-data" version = "0.6.3"