pyeasydeploy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyeasydeploy
3
+ Version: 0.1.0
4
+ Summary: Simple and replicable Python server deployment toolkit
5
+ Author: Beltrán Offerrall
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/offerrall/pyeasydeploy
8
+ Project-URL: Repository, https://github.com/offerrall/pyeasydeploy
9
+ Project-URL: Issues, https://github.com/offerrall/pyeasydeploy/issues
10
+ Keywords: deployment,devops,automation,ssh,fabric,supervisor
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: System :: Installation/Setup
21
+ Classifier: Topic :: System :: Systems Administration
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: fabric>=3.0.0
26
+
27
+ # pyeasydeploy 0.1.0
28
+
29
+ A small library for deploying Python applications to Linux servers over SSH. Plain Python functions on top of [Fabric](https://www.fabfile.org/): no agents on the server, no YAML, no DSL to learn. Your deploy script reads top to bottom.
30
+
31
+ It doesn't try to compete with Ansible or Docker. If you have a few servers, you write Python, and you want your deploy to be just another `deploy.py` in your project, it might be for you.
32
+
33
+ ## A complete deploy
34
+
35
+ ```python
36
+ from pyeasydeploy import (
37
+ SupervisorService, connect_to_host, create_venv,
38
+ deploy_supervisor_service, get_target_python_instance,
39
+ install_local_package,
40
+ )
41
+
42
+ APP = "myapp"
43
+ USER = "deploy"
44
+
45
+ conn = connect_to_host(
46
+ host="203.0.113.10",
47
+ user=USER,
48
+ key_filename="~/.ssh/id_ed25519",
49
+ sudo_password="...", # better: os.environ["SUDO_PASSWORD"]
50
+ )
51
+
52
+ py = get_target_python_instance(conn, "3.11")
53
+ venv = create_venv(conn, py, f"/home/{USER}/venvs/{APP}")
54
+ install_local_package(conn, venv, f"./{APP}")
55
+
56
+ deploy_supervisor_service(conn, SupervisorService(
57
+ name=APP,
58
+ command=f"{venv.venv_path}/bin/python -m {APP}",
59
+ directory=f"/home/{USER}",
60
+ user=USER,
61
+ ))
62
+ ```
63
+
64
+ Connect, pick an interpreter, create the venv, install your package with its dependencies, and leave it running as a supervised service that survives reboots. The `venv` object returned by `create_venv` carries its own path: the service command is built from it, no paths repeated by hand.
65
+
66
+ ## The ideas behind it
67
+
68
+ **Destructive and reproducible.** Uploads remove the destination and copy from scratch, every time. After each deploy, the server has exactly what you have locally — no leftovers from previous versions. This is not configurable; it's the contract. (The one safety net: paths like `/`, `/home` or `/etc` are rejected as destinations.)
69
+
70
+ **Fail early, fail clearly.** Models validate on construction: a relative path or a service name that would corrupt the INI file blows up on your laptop with a useful message, before touching the server. Functions that need sudo check for it upfront — an immediate error with instructions, instead of the classic hang waiting for a password that will never come.
71
+
72
+ **Trust the user.** The library validates *form* (types, absolute paths, dangerous characters), not your *facts*: if you hand-build a `PythonInstance` pointing at an exotic interpreter, it's accepted. You know what's on your server.
73
+
74
+ ## Installation
75
+
76
+ ```bash
77
+ pip install pyeasydeploy
78
+ ```
79
+
80
+ Python ≥ 3.10 on your machine. On the server: SSH and some `python3` (tested on Debian/Ubuntu).
81
+
82
+ ## Quick guide
83
+
84
+ ### Connecting
85
+
86
+ ```python
87
+ conn = connect_to_host(host, user, password="...") # password (reused for sudo)
88
+ conn = connect_to_host(host, user, key_filename="~/.ssh/id_ed25519") # SSH key
89
+ ```
90
+
91
+ With key auth and sudo operations, add `sudo_password=`. The connection is lazy: a wrong password shows up on the first command, not at connect time.
92
+
93
+ ### Remote Python
94
+
95
+ ```python
96
+ py = get_any_python_instance(conn) # newest on the server
97
+ py = get_target_python_instance(conn, "3.11") # a specific one
98
+ ```
99
+
100
+ Only real interpreters are matched (`python3.X-config` and friends are filtered out), and version matching is component-wise: `"3.1"` means 3.1, not 3.11. For non-standard locations, build the model yourself:
101
+
102
+ ```python
103
+ py = PythonInstance(version="3.12", executable="/opt/py312/bin/python3.12")
104
+ ```
105
+
106
+ ### Venvs and packages
107
+
108
+ ```python
109
+ venv = create_venv(conn, py, "/home/deploy/venvs/myapp") # idempotent
110
+
111
+ install_packages(conn, venv, ["fastapi", "uvicorn[standard]"])
112
+ install_local_package(conn, venv, "./myapp")
113
+ install_package_from_private_github(conn, venv, "git@github.com:org/private.git")
114
+
115
+ run_in_venv(conn, venv, "python -m myapp --check")
116
+ ```
117
+
118
+ Installs use `uv` inside the venv (fast; `use_uv=False` for classic pip). Private repos are cloned **on your machine** with your own credentials, then the source is uploaded: the server never needs access to your GitHub.
119
+
120
+ ### Files
121
+
122
+ ```python
123
+ upload_directory(conn, "./data", "/home/deploy/data")
124
+ upload_file(conn, "config.toml", "/home/deploy/myapp/config.toml")
125
+ ```
126
+
127
+ ⚠️ Destructive: the destination is removed before copying. `.git`, `__pycache__`, venvs and similar are excluded by default (`DEFAULT_IGNORE`); pass `ignore=[]` to upload everything.
128
+
129
+ ### Services
130
+
131
+ ```python
132
+ install_supervisor(conn) # once per server
133
+
134
+ deploy_supervisor_service(conn, SupervisorService(
135
+ name="myapp",
136
+ command=f"{venv.venv_path}/bin/python -m myapp",
137
+ extra={
138
+ "stdout_logfile_maxbytes": "10MB", # any supervisord option,
139
+ "stdout_logfile_backups": 5, # passed through verbatim
140
+ "stopsignal": "INT",
141
+ },
142
+ ))
143
+
144
+ supervisor_status(conn)
145
+ supervisor_restart(conn, "myapp")
146
+ ```
147
+
148
+ Named fields cover the common cases; the `extra` dict accepts any supervisord option with no restrictions — the library only blocks what would corrupt the generated file.
149
+
150
+ ## What it is not
151
+
152
+ - **Not Ansible/Terraform.** No inventories, no state, no declarative idempotency. Imperative on purpose.
153
+ - **Not provisioning.** It installs supervisor because services are its job, and that's where it stops: nginx, databases and the rest of your server are up to you.
154
+ - **No secret management.** The passwords you pass in are your environment's responsibility.
155
+ - **No fleet orchestration.** One connection, one server. For several, write a loop.
156
+ - **Linux targets only.** The source machine can be Windows, macOS or Linux.
157
+
158
+ For many of those cases, bigger tools will do it better. This one exists for when you don't need them.
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,136 @@
1
+ # pyeasydeploy 0.1.0
2
+
3
+ A small library for deploying Python applications to Linux servers over SSH. Plain Python functions on top of [Fabric](https://www.fabfile.org/): no agents on the server, no YAML, no DSL to learn. Your deploy script reads top to bottom.
4
+
5
+ It doesn't try to compete with Ansible or Docker. If you have a few servers, you write Python, and you want your deploy to be just another `deploy.py` in your project, it might be for you.
6
+
7
+ ## A complete deploy
8
+
9
+ ```python
10
+ from pyeasydeploy import (
11
+ SupervisorService, connect_to_host, create_venv,
12
+ deploy_supervisor_service, get_target_python_instance,
13
+ install_local_package,
14
+ )
15
+
16
+ APP = "myapp"
17
+ USER = "deploy"
18
+
19
+ conn = connect_to_host(
20
+ host="203.0.113.10",
21
+ user=USER,
22
+ key_filename="~/.ssh/id_ed25519",
23
+ sudo_password="...", # better: os.environ["SUDO_PASSWORD"]
24
+ )
25
+
26
+ py = get_target_python_instance(conn, "3.11")
27
+ venv = create_venv(conn, py, f"/home/{USER}/venvs/{APP}")
28
+ install_local_package(conn, venv, f"./{APP}")
29
+
30
+ deploy_supervisor_service(conn, SupervisorService(
31
+ name=APP,
32
+ command=f"{venv.venv_path}/bin/python -m {APP}",
33
+ directory=f"/home/{USER}",
34
+ user=USER,
35
+ ))
36
+ ```
37
+
38
+ Connect, pick an interpreter, create the venv, install your package with its dependencies, and leave it running as a supervised service that survives reboots. The `venv` object returned by `create_venv` carries its own path: the service command is built from it, no paths repeated by hand.
39
+
40
+ ## The ideas behind it
41
+
42
+ **Destructive and reproducible.** Uploads remove the destination and copy from scratch, every time. After each deploy, the server has exactly what you have locally — no leftovers from previous versions. This is not configurable; it's the contract. (The one safety net: paths like `/`, `/home` or `/etc` are rejected as destinations.)
43
+
44
+ **Fail early, fail clearly.** Models validate on construction: a relative path or a service name that would corrupt the INI file blows up on your laptop with a useful message, before touching the server. Functions that need sudo check for it upfront — an immediate error with instructions, instead of the classic hang waiting for a password that will never come.
45
+
46
+ **Trust the user.** The library validates *form* (types, absolute paths, dangerous characters), not your *facts*: if you hand-build a `PythonInstance` pointing at an exotic interpreter, it's accepted. You know what's on your server.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install pyeasydeploy
52
+ ```
53
+
54
+ Python ≥ 3.10 on your machine. On the server: SSH and some `python3` (tested on Debian/Ubuntu).
55
+
56
+ ## Quick guide
57
+
58
+ ### Connecting
59
+
60
+ ```python
61
+ conn = connect_to_host(host, user, password="...") # password (reused for sudo)
62
+ conn = connect_to_host(host, user, key_filename="~/.ssh/id_ed25519") # SSH key
63
+ ```
64
+
65
+ With key auth and sudo operations, add `sudo_password=`. The connection is lazy: a wrong password shows up on the first command, not at connect time.
66
+
67
+ ### Remote Python
68
+
69
+ ```python
70
+ py = get_any_python_instance(conn) # newest on the server
71
+ py = get_target_python_instance(conn, "3.11") # a specific one
72
+ ```
73
+
74
+ Only real interpreters are matched (`python3.X-config` and friends are filtered out), and version matching is component-wise: `"3.1"` means 3.1, not 3.11. For non-standard locations, build the model yourself:
75
+
76
+ ```python
77
+ py = PythonInstance(version="3.12", executable="/opt/py312/bin/python3.12")
78
+ ```
79
+
80
+ ### Venvs and packages
81
+
82
+ ```python
83
+ venv = create_venv(conn, py, "/home/deploy/venvs/myapp") # idempotent
84
+
85
+ install_packages(conn, venv, ["fastapi", "uvicorn[standard]"])
86
+ install_local_package(conn, venv, "./myapp")
87
+ install_package_from_private_github(conn, venv, "git@github.com:org/private.git")
88
+
89
+ run_in_venv(conn, venv, "python -m myapp --check")
90
+ ```
91
+
92
+ Installs use `uv` inside the venv (fast; `use_uv=False` for classic pip). Private repos are cloned **on your machine** with your own credentials, then the source is uploaded: the server never needs access to your GitHub.
93
+
94
+ ### Files
95
+
96
+ ```python
97
+ upload_directory(conn, "./data", "/home/deploy/data")
98
+ upload_file(conn, "config.toml", "/home/deploy/myapp/config.toml")
99
+ ```
100
+
101
+ ⚠️ Destructive: the destination is removed before copying. `.git`, `__pycache__`, venvs and similar are excluded by default (`DEFAULT_IGNORE`); pass `ignore=[]` to upload everything.
102
+
103
+ ### Services
104
+
105
+ ```python
106
+ install_supervisor(conn) # once per server
107
+
108
+ deploy_supervisor_service(conn, SupervisorService(
109
+ name="myapp",
110
+ command=f"{venv.venv_path}/bin/python -m myapp",
111
+ extra={
112
+ "stdout_logfile_maxbytes": "10MB", # any supervisord option,
113
+ "stdout_logfile_backups": 5, # passed through verbatim
114
+ "stopsignal": "INT",
115
+ },
116
+ ))
117
+
118
+ supervisor_status(conn)
119
+ supervisor_restart(conn, "myapp")
120
+ ```
121
+
122
+ Named fields cover the common cases; the `extra` dict accepts any supervisord option with no restrictions — the library only blocks what would corrupt the generated file.
123
+
124
+ ## What it is not
125
+
126
+ - **Not Ansible/Terraform.** No inventories, no state, no declarative idempotency. Imperative on purpose.
127
+ - **Not provisioning.** It installs supervisor because services are its job, and that's where it stops: nginx, databases and the rest of your server are up to you.
128
+ - **No secret management.** The passwords you pass in are your environment's responsibility.
129
+ - **No fleet orchestration.** One connection, one server. For several, write a loop.
130
+ - **Linux targets only.** The source machine can be Windows, macOS or Linux.
131
+
132
+ For many of those cases, bigger tools will do it better. This one exists for when you don't need them.
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,103 @@
1
+ """pyeasydeploy — deploy Python apps to Linux servers over SSH.
2
+
3
+ No agents, no YAML, no magic: plain Python functions that do exactly
4
+ what they say. See the README for the philosophy (destructive and
5
+ reproducible uploads, fail-fast validation, trust in the user).
6
+
7
+ Typical flow::
8
+
9
+ from pyeasydeploy import (
10
+ connect_to_host, get_target_python_instance, create_venv,
11
+ install_local_package, deploy_supervisor_service,
12
+ SupervisorService,
13
+ )
14
+
15
+ conn = connect_to_host(host, user, key_filename="~/.ssh/id_ed25519",
16
+ sudo_password="...")
17
+ py = get_target_python_instance(conn, "3.11")
18
+ venv = create_venv(conn, py, "/home/deploy/venvs/myapp")
19
+ install_local_package(conn, venv, "./myapp")
20
+ deploy_supervisor_service(conn, SupervisorService(
21
+ name="myapp",
22
+ command="/home/deploy/venvs/myapp/bin/python -m myapp",
23
+ ))
24
+ """
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ # Models (foundation layer; importable standalone)
29
+ from .models import PythonInstance, SupervisorService, VenvPython
30
+
31
+ # Connection
32
+ from .connection import connect_to_host, has_sudo_password, require_sudo
33
+
34
+ # Remote Python discovery
35
+ from .python import (
36
+ get_any_python_instance,
37
+ get_python_instances,
38
+ get_target_python_instance,
39
+ )
40
+
41
+ # Virtual environments
42
+ from .venv import create_venv, delete_venv, run_in_venv
43
+
44
+ # File transfer
45
+ from .transfer import DEFAULT_IGNORE, upload_directory, upload_file
46
+
47
+ # Package installation
48
+ from .packages import (
49
+ install_local_package,
50
+ install_package_from_github,
51
+ install_package_from_private_github,
52
+ install_packages,
53
+ )
54
+
55
+ # Supervisor services
56
+ from .supervisor import (
57
+ check_supervisor_installed,
58
+ create_supervisor_config,
59
+ deploy_supervisor_service,
60
+ install_supervisor,
61
+ supervisor_restart,
62
+ supervisor_start,
63
+ supervisor_status,
64
+ supervisor_stop,
65
+ )
66
+
67
+ __all__ = [
68
+ "__version__",
69
+ # models
70
+ "PythonInstance",
71
+ "VenvPython",
72
+ "SupervisorService",
73
+ # connection
74
+ "connect_to_host",
75
+ "has_sudo_password",
76
+ "require_sudo",
77
+ # python
78
+ "get_python_instances",
79
+ "get_target_python_instance",
80
+ "get_any_python_instance",
81
+ # venv
82
+ "create_venv",
83
+ "delete_venv",
84
+ "run_in_venv",
85
+ # transfer
86
+ "upload_file",
87
+ "upload_directory",
88
+ "DEFAULT_IGNORE",
89
+ # packages
90
+ "install_packages",
91
+ "install_local_package",
92
+ "install_package_from_github",
93
+ "install_package_from_private_github",
94
+ # supervisor
95
+ "install_supervisor",
96
+ "check_supervisor_installed",
97
+ "create_supervisor_config",
98
+ "deploy_supervisor_service",
99
+ "supervisor_start",
100
+ "supervisor_stop",
101
+ "supervisor_restart",
102
+ "supervisor_status",
103
+ ]
@@ -0,0 +1,153 @@
1
+ """SSH connection factory for pyeasydeploy.
2
+
3
+ Builds Fabric Connection objects with authentication and sudo correctly
4
+ wired. Depends only on models-level philosophy (validate at the
5
+ boundary); imports nothing from the rest of the package.
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from fabric import Connection
11
+
12
+
13
+ def connect_to_host(
14
+ host: str,
15
+ user: str,
16
+ password: Optional[str] = None,
17
+ key_filename: Optional[str] = None,
18
+ sudo_password: Optional[str] = None,
19
+ port: int = 22,
20
+ ) -> Connection:
21
+ """Create an SSH connection to a remote host.
22
+
23
+ Exactly one of ``password`` or ``key_filename`` must be provided
24
+ for SSH authentication.
25
+
26
+ Sudo is configured independently: if ``sudo_password`` is given it
27
+ is used for ``conn.sudo()`` calls; otherwise, if ``password`` is
28
+ given it is reused for sudo (the common case where the SSH user's
29
+ password is also the sudo password). With key-based auth and no
30
+ ``sudo_password``, any later ``conn.sudo()`` call would block
31
+ forever waiting for a password prompt — so functions in this
32
+ library that need sudo will refuse early instead (see
33
+ ``require_sudo``).
34
+
35
+ Note: the connection is lazy. Fabric does not open the SSH session
36
+ here; authentication errors surface on the first command run.
37
+
38
+ Args:
39
+ host: Remote host address (IP or hostname).
40
+ user: Username for the SSH connection.
41
+ password: Password for SSH authentication. Mutually exclusive
42
+ with key_filename.
43
+ key_filename: Path to an SSH private key file. Mutually
44
+ exclusive with password.
45
+ sudo_password: Password for sudo on the remote host. Defaults
46
+ to ``password`` when that is provided. Required later by
47
+ any sudo-using function when authenticating with a key
48
+ (unless the remote user has passwordless sudo configured,
49
+ in which case sudo works without it).
50
+ port: SSH port. Defaults to 22.
51
+
52
+ Returns:
53
+ A Fabric Connection, with sudo password configured when
54
+ available.
55
+
56
+ Raises:
57
+ TypeError: If any argument has the wrong type.
58
+ ValueError: If host or user are empty, both or neither of
59
+ password/key_filename are provided, or port is outside
60
+ 1-65535.
61
+ """
62
+ if not isinstance(host, str):
63
+ raise TypeError(f"host must be str, got {type(host).__name__}")
64
+ if not host.strip():
65
+ raise ValueError("host must be a non-empty string")
66
+ if not isinstance(user, str):
67
+ raise TypeError(f"user must be str, got {type(user).__name__}")
68
+ if not user.strip():
69
+ raise ValueError("user must be a non-empty string")
70
+ for name, value in (
71
+ ("password", password),
72
+ ("key_filename", key_filename),
73
+ ("sudo_password", sudo_password),
74
+ ):
75
+ if value is not None and not isinstance(value, str):
76
+ raise TypeError(f"{name} must be str or None, got {type(value).__name__}")
77
+ if isinstance(port, bool) or not isinstance(port, int):
78
+ raise TypeError(f"port must be int, got {type(port).__name__}")
79
+ if not 1 <= port <= 65535:
80
+ raise ValueError(f"port must be in 1-65535, got {port}")
81
+
82
+ if password is None and key_filename is None:
83
+ raise ValueError(
84
+ "You must provide either 'password' or 'key_filename' "
85
+ "for authentication"
86
+ )
87
+ if password is not None and key_filename is not None:
88
+ raise ValueError("Provide either 'password' or 'key_filename', not both")
89
+
90
+ connect_kwargs = {}
91
+ if password is not None:
92
+ connect_kwargs["password"] = password
93
+ else:
94
+ connect_kwargs["key_filename"] = key_filename
95
+
96
+ conn = Connection(
97
+ host=host,
98
+ user=user,
99
+ port=port,
100
+ connect_kwargs=connect_kwargs,
101
+ )
102
+
103
+ effective_sudo = sudo_password if sudo_password is not None else password
104
+ if effective_sudo is not None:
105
+ conn.config.sudo.password = effective_sudo
106
+
107
+ return conn
108
+
109
+
110
+ def has_sudo_password(conn: Connection) -> bool:
111
+ """Return True if a sudo password is configured on this connection.
112
+
113
+ Args:
114
+ conn: Connection created by connect_to_host (or any Fabric
115
+ Connection).
116
+
117
+ Returns:
118
+ True when conn.config.sudo.password is set to a non-empty value.
119
+ """
120
+ return bool(getattr(conn.config.sudo, "password", None))
121
+
122
+
123
+ def require_sudo(conn: Connection, what: str = "this operation") -> None:
124
+ """Fail fast if the connection cannot run sudo non-interactively.
125
+
126
+ Call this at the top of any function that uses ``conn.sudo()``.
127
+ Turns the silent infinite hang (sudo waiting for a password that
128
+ will never arrive) into an immediate, explanatory error.
129
+
130
+ Note: a remote user with passwordless sudo (NOPASSWD in sudoers)
131
+ does not need a sudo password; pass an empty-string check bypass by
132
+ configuring ``sudo_password=""`` is NOT supported — instead, this
133
+ check is advisory: it only raises when no password is configured
134
+ AND the connection was key-authenticated, the exact combination
135
+ that hangs.
136
+
137
+ Args:
138
+ conn: Connection to check.
139
+ what: Human description of the operation, used in the error
140
+ message.
141
+
142
+ Raises:
143
+ PermissionError: If no sudo password is configured and the
144
+ connection has no SSH password to fall back on.
145
+ """
146
+ if has_sudo_password(conn):
147
+ return
148
+ raise PermissionError(
149
+ f"{what} requires sudo, but no sudo password is configured for "
150
+ f"this connection. Pass sudo_password= to connect_to_host(), or "
151
+ f"configure passwordless sudo (NOPASSWD) for the remote user and "
152
+ f"call the function with check_sudo=False if it offers it."
153
+ )