Repository: pyinfra-dev/pyinfra

Issue:
Using apt.packages with the cli fails
## Describe the bug
Using `apt.packages` with the cli fails with error, using versions 3.x
I just found this tool, and am following the example on the github readme

## To Reproduce
```
pyinfra @local apt.packages iftop update=true _sudo=true

--> pyinfra error: No such attribute in module apt: packages
```

If I downgrade, with `pip install "pyinfra<3" `, then it works.


## Expected behavior
Install iftop.

## Meta
```
pyinfra --support

    If you are having issues with pyinfra or wish to make feature requests, please
    check out the GitHub issues at https://github.com/Fizzadar/pyinfra/issues .
    When adding an issue, be sure to include the following:

    System: Linux
      Platform: Linux-6.8.0-53-generic-x86_64-with-glibc2.39
      Release: 6.8.0-53-generic
      Machine: x86_64
    pyinfra: v3.2
      click: v8.1.6
      coverage: v7.6.9
      coverage: v7.6.9
      distro: v1.9.0
      flake8: v7.0.0
      flake8: v7.0.0
      gevent: v24.11.1
      importlib-metadata: v4.12.0
      ipython: v8.20.0
      jinja2: v3.1.2
      packaging: v24.0
      paramiko: v3.5.0
      pytest: v8.3.5
      pytest: v8.3.5
      pytest-cov: v4.1.0
      pytest-cov: v4.1.0
      python-dateutil: v2.8.2
      pywinrm: v0.5.0
      setuptools: v75.8.0
      typeguard: v4.4.2
      types-PyYAML: v6.0
      types-PyYAML: v6.0
      types-paramiko: v3.3
      types-paramiko: v3.3
      types-python-dateutil: v2.8
      types-python-dateutil: v2.8
      types-setuptools: v68.2
      types-setuptools: v68.2
      typing-extensions: v4.12.2
      wheel: v0.42.0
    Executable: /home/aaron/.local/bin/pyinfra
    Python: 3.12.3 (CPython, GCC 13.3.0)
```

Discussion:
@wowi42: Hey @aaron-riact 

I just conducted a test inside an Ubuntu 24.04 container and encountered no issues.

What I did

```
docker run -it ubuntu:24.04 bash
```

then

```
apt update
apt upgrade
apt install python3.12 python3.12-venv
python3.12 -m venv env
source env/bin/activate
pip install pyinfra
```

then:

```
(env) root@7b704057ffd4:/# pyinfra @local apt.packages iftop update=true
--> Loading config...
--> Loading inventory...
--> Connecting to hosts...
    [@local] Connected

--> Preparing operations...
--> Preparing operation...
    [@local] Ready: packages

--> Detected changes:
    Operation                           Change       Conditional Change
    apt.packages (iftop, update=True)   1 (@local)   -

    Detected changes may not include every change pyinfra will execute.
    Hidden side effects of operations may alter behaviour of future operations,
    this will be shown in the results. The remote state will always be updated
    to reflect the state defined by the input operations.

    Detected changes displayed above, skip this step with -y

--> Beginning operation run...
--> Starting operation: apt.packages (iftop, update=True)
    [@local] Success

--> Results:
    Operation                           Hosts   Success   Error   No Change
    apt.packages (iftop, update=True)   1       1         -       -
    Grand total                         1       1         -       -

--> Disconnecting from hosts...

```


here is my pyinfra support

```
(env) root@7b704057ffd4:/# pyinfra --support

    If you are having issues with pyinfra or wish to make feature requests, please
    check out the GitHub issues at https://github.com/Fizzadar/pyinfra/issues .
    When adding an issue, be sure to include the following:

    System: Linux
      Platform: Linux-6.13.8-arch1-1-x86_64-with-glibc2.39
      Release: 6.13.8-arch1-1
      Machine: x86_64
    pyinfra: v3.2
      click: v8.1.8
      distro: v1.9.0
      gevent: v24.11.1
      jinja2: v3.1.6
      packaging: v24.2
      paramiko: v3.5.1
      python-dateutil: v2.9.0.post0
      pywinrm: v0.5.0
      setuptools: v78.1.0
      typeguard: v4.4.2
      typing-extensions: v4.13.1
    Executable: /env/bin/pyinfra
    Python: 3.12.3 (CPython, GCC 13.3.0)
```

Could you please confirm how you installed it?
@aaron-riact: hmm.  I tried on two different machines, both running ubuntu 24.04.  I tried with `pip install pyinfra --break-system-packages`, and got the error above.

I tried also with `pipx install pyinfra`, and also got the same error.

Ah, now that I remove the system install, and reinstall with `pipx` it works.
And yes, if I follow your install with `venv` it also works nicely.

Thanks @wowi42 !@wowi42: @aaron-riact I think you can close it, no?@aaron-riact: yes, I can close.  My issue is resolved, in that I can run pyinfra now following best practices, thanks)

More generally, is there still an issue to address though? as in, when I installed it system wide, it didnt break any system packages, rather it was pyinfra itself that didn't work. While its discouraged, its still pretty easy to use `--break-system-packages` if one is in a hurry on a ephemeral system for example and indeed this is the first package i've encountered an issue with doing it like this.
   
And it broke in a somewhat inconsistent way, in that `apt.packages` didn't work, but (from memory) `dnf.packages` worked just fine, so it wasn't at all clear what was happening.  I can imagine others might install it likewise, and not run into any issues for some time, then the problem becomes harder to track down.

Perhaps it can be considered a documentation issue, in that along with the recommended way of installing, there could also be a warning saying eg "dont install it system wide after python 3.12, it wont work".@wowi42: @aaron-riact updated the documentation@aaron-riact: awesome, thanks for your engagement and support @wowi42 

Solution patch:
diff --git a/docs/install.md b/docs/install.md
index e43cbbb3..4a36c265 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -5,31 +5,124 @@ orphan: true
 
 # Installation
 
-## Pip
+## Prerequisites
 
-It is recommended to install pyinfra using `pip`:
+### Python Version Requirements
 
-```sh
-pip install pyinfra
-```
+- Python 3.9 or later is required
+- pyinfra 3.2+ supports Python 3.9, 3.10, 3.11, and 3.12
+- You can check your Python version with:
+
+  ```sh
+  python --version
+  ```
+
+### System Requirements
+
+- A Unix-like operating system (Linux, macOS) or Windows
+- Shell access to target systems
+- For Windows users: Administrator privileges for installation
+- For development: A C++ compiler may be required for some Python packages
+
+## Installation Method
+
+### Using pip (Recommended)
+
+#### Create a Virtual Environment (Best Practice)
+
+   ```sh
+   # Create a virtual environment
+   python -m venv env
+
+   # Activate the virtual environment
+   # On macOS/Linux
+   source env/bin/activate
+   # On Windows
+   env\Scripts\activate
+   ```
+
+#### Install pyinfra
+
+   ```sh
+   pip install pyinfra
+   ```
+
+#### Verify Installation
+
+   ```sh
+   pyinfra --version
+   ```
+
+## Platform-Specific Instructions
+
+### Windows Installation
+
+#### Install Python
 
-## Windows
+- Download from [Python's official website](https://www.python.org/downloads/windows/)
+- Run installer as Administrator
+- Check "Add Python to PATH" during installation
 
-Tested on WindowsServer2019 with python 3.7.
+#### Optional: Install C++ Compiler
 
-+ Download Python https://www.python.org/downloads/windows/
-  (ex: python-3.7.6-amd64.exe). Install as Administrator and
-  ensure the **Add Python to PATH** option is selected.)
+- Download [Visual Studio Community Edition](https://visualstudio.microsoft.com/downloads/)
+- Select "Desktop development with C++" workload
+- Required components:
+  - MSVC v142...
+  - Windows 10 SDK...
+  - C++ CMake tools for Windows
+  - C++ ATL for latest...
+  - C++/CLI support for v142...
+  - C++ Modules for v142...
 
-If you need to build any python packages on Windows, perhaps because one of the **pip** packages above fails, you may need a c++ compiler. One possible solution is below.
+#### Install pyinfra
 
-+ Download Visual Studio Community Edition https://visualstudio.microsoft.com/downloads/ and
-  install Visual Studio as Administrator. Select the "Desktop development with C++" option and
-  ensure at least these options are selected:
+   ```sh
+   python -m venv env
+   env\Scripts\activate
+   pip install pyinfra
+   ```
 
-    + "MSVC v142..."
-    + "Windows 10 SDK..."
-    + "C++ cmake tools for windows"
-    + "C++ ATL for latest..."
-    + "C++/cli support for v142..."
-    + "C++ Modules for v142..."
+## Best Practices
+
+### Do's
+
+- ✅ Use virtual environments for isolation
+- ✅ Keep pyinfra and dependencies up-to-date
+- ✅ Use the latest Python version supported
+- ✅ Install as a regular user (not root/sudo)
+
+### Don'ts
+
+- ❌ Avoid system-wide installation
+- ❌ Don't mix pyinfra with other projects in the same virtual environment
+- ❌ Don't use unsupported package managers (poetry, pip-tools)
+
+## Troubleshooting
+
+### Common Issues
+
+#### Permission Errors
+
+- Ensure you're not using sudo for installation
+- Check virtual environment permissions
+- Verify Python installation directory permissions
+
+#### Python Version Issues
+
+- Verify Python version meets requirements
+- Consider using pyenv or similar version manager
+
+#### Virtual Environment Issues
+
+- Ensure virtual environment is properly activated
+- Check PATH environment variable
+- Verify Python interpreter location
+
+## Updating pyinfra
+
+To update to the latest version:
+
+```sh
+pip install --upgrade pyinfra
+```
diff --git a/pyinfra/connectors/sshuserclient/client.py b/pyinfra/connectors/sshuserclient/client.py
index eef4d60d..cfb022af 100644
--- a/pyinfra/connectors/sshuserclient/client.py
+++ b/pyinfra/connectors/sshuserclient/client.py
@@ -158,6 +158,7 @@ class SSHClient(ParamikoClient):
             forward_agent,
             missing_host_key_policy,
             host_keys_file,
+            keep_alive,
         ) = self.parse_config(
             hostname,
             kwargs,
@@ -183,8 +184,6 @@ class SSHClient(ParamikoClient):
         if _pyinfra_ssh_forward_agent is not None:
             forward_agent = _pyinfra_ssh_forward_agent
 
-        keep_alive = config.get("keep_alive")
-
         if keep_alive:
             transport = self.get_transport()
             assert transport is not None, "No transport"
@@ -215,13 +214,14 @@ class SSHClient(ParamikoClient):
         cfg: dict = {"port": 22}
         cfg.update(initial_cfg or {})
 
+        keep_alive = 0
         forward_agent = False
         missing_host_key_policy = get_missing_host_key_policy(strict_host_key_checking)
         host_keys_file = path.expanduser("~/.ssh/known_hosts")  # OpenSSH default
 
         ssh_config = get_ssh_config(ssh_config_file)
         if not ssh_config:
-            return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file
+            return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file, keep_alive
 
         host_config = ssh_config.lookup(hostname)
         forward_agent = host_config.get("forwardagent") == "yes"
@@ -248,7 +248,7 @@ class SSHClient(ParamikoClient):
             cfg["port"] = int(host_config["port"])
 
         if "serveraliveinterval" in host_config:
-            cfg["keep_alive"] = int(host_config["serveraliveinterval"])
+            keep_alive = int(host_config["serveraliveinterval"])
 
         if "proxycommand" in host_config:
             cfg["sock"] = ProxyCommand(host_config["proxycommand"])
@@ -275,7 +275,7 @@ class SSHClient(ParamikoClient):
                 sock = c.gateway(hostname, cfg["port"], target, target_config["port"])
             cfg["sock"] = sock
 
-        return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file
+        return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file, keep_alive
 
     @staticmethod
     def derive_shorthand(ssh_config, host_string):


Test patch:
diff --git a/tests/test_connectors/test_sshuserclient.py b/tests/test_connectors/test_sshuserclient.py
index 28f1468a..eea2e6e1 100644
--- a/tests/test_connectors/test_sshuserclient.py
+++ b/tests/test_connectors/test_sshuserclient.py
@@ -78,8 +78,10 @@ class TestSSHUserConfigMissing(TestCase):
     def test_load_ssh_config_no_exist(self):
         client = SSHClient()
 
-        _, config, forward_agent, missing_host_key_policy, host_keys_file = client.parse_config(
-            "127.0.0.1",
+        _, config, forward_agent, missing_host_key_policy, host_keys_file, keep_alive = (
+            client.parse_config(
+                "127.0.0.1",
+            )
         )
 
         assert config.get("port") == 22
@@ -126,8 +128,10 @@ class TestSSHUserConfig(TestCase):
     def test_load_ssh_config(self):
         client = SSHClient()
 
-        _, config, forward_agent, missing_host_key_policy, host_keys_file = client.parse_config(
-            "127.0.0.1",
+        _, config, forward_agent, missing_host_key_policy, host_keys_file, keep_alive = (
+            client.parse_config(
+                "127.0.0.1",
+            )
         )
 
         assert config.get("key_filename") == ["/id_rsa", "/id_rsa2"]
@@ -144,6 +148,7 @@ class TestSSHUserConfig(TestCase):
             forward_agent,
             missing_host_key_policy,
             host_keys_file,
+            keep_alive,
         ) = client.parse_config("192.168.1.1")
 
         assert other_config.get("username") == "otheruser"
@@ -198,7 +203,7 @@ class TestSSHUserConfig(TestCase):
         client = SSHClient()
 
         # Load the SSH config with ProxyJump configured
-        _, config, forward_agent, _, _ = client.parse_config(
+        _, config, forward_agent, _, _, _ = client.parse_config(
             "192.168.1.2",
             {"port": 1022},
             ssh_config_file="other_file",