pyeasydeploy 0.1.0__py3-none-any.whl
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.
- pyeasydeploy/__init__.py +103 -0
- pyeasydeploy/connection.py +153 -0
- pyeasydeploy/models.py +241 -0
- pyeasydeploy/packages.py +187 -0
- pyeasydeploy/py.typed +0 -0
- pyeasydeploy/python.py +141 -0
- pyeasydeploy/supervisor.py +216 -0
- pyeasydeploy/transfer.py +174 -0
- pyeasydeploy/venv.py +122 -0
- pyeasydeploy-0.1.0.dist-info/METADATA +162 -0
- pyeasydeploy-0.1.0.dist-info/RECORD +13 -0
- pyeasydeploy-0.1.0.dist-info/WHEEL +5 -0
- pyeasydeploy-0.1.0.dist-info/top_level.txt +1 -0
pyeasydeploy/__init__.py
ADDED
|
@@ -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
|
+
)
|
pyeasydeploy/models.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Shared data models for pyeasydeploy.
|
|
2
|
+
|
|
3
|
+
This module is the foundation layer: it imports nothing from the rest of
|
|
4
|
+
the package, so every other module can depend on it without cycles.
|
|
5
|
+
|
|
6
|
+
All models are frozen (immutable) and validate themselves on
|
|
7
|
+
construction: wrong types raise TypeError, invalid values raise
|
|
8
|
+
ValueError. Validation is structural only — it protects the integrity
|
|
9
|
+
of what we generate (paths that must be absolute to work remotely, INI
|
|
10
|
+
section headers that must parse) and never restricts which supervisord
|
|
11
|
+
features you can use.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from types import MappingProxyType
|
|
16
|
+
from typing import Mapping, Optional, Union
|
|
17
|
+
|
|
18
|
+
# ----------------------------------------------------------------------
|
|
19
|
+
# Private validation helpers (shared by all models)
|
|
20
|
+
# ----------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _check_str(field_name: str, value: object) -> str:
|
|
24
|
+
"""Require a non-empty, non-blank str. Returns the value."""
|
|
25
|
+
if not isinstance(value, str):
|
|
26
|
+
raise TypeError(f"{field_name} must be str, got {type(value).__name__}")
|
|
27
|
+
if not value.strip():
|
|
28
|
+
raise ValueError(f"{field_name} must be a non-empty string")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_abs_posix_path(field_name: str, value: object) -> str:
|
|
33
|
+
"""Require a non-empty str that is an absolute POSIX path (remote
|
|
34
|
+
hosts are Linux: paths must start with '/')."""
|
|
35
|
+
_check_str(field_name, value)
|
|
36
|
+
assert isinstance(value, str)
|
|
37
|
+
if not value.startswith("/"):
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"{field_name} must be an absolute POSIX path (start with '/'), "
|
|
40
|
+
f"got {value!r}"
|
|
41
|
+
)
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_bool(field_name: str, value: object) -> bool:
|
|
46
|
+
"""Require a real bool (bool is a subclass of int, so a plain
|
|
47
|
+
isinstance(int) check would let 0/1 through)."""
|
|
48
|
+
if not isinstance(value, bool):
|
|
49
|
+
raise TypeError(f"{field_name} must be bool, got {type(value).__name__}")
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ----------------------------------------------------------------------
|
|
54
|
+
# Models
|
|
55
|
+
# ----------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class PythonInstance:
|
|
60
|
+
"""A Python interpreter found on the remote host.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
version: Interpreter version string, e.g. "3.11". Must start
|
|
64
|
+
with a digit.
|
|
65
|
+
executable: Absolute remote path to the interpreter,
|
|
66
|
+
e.g. "/usr/bin/python3.11".
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
TypeError: On construction, if any field has the wrong type.
|
|
70
|
+
ValueError: On construction, if version is empty or does not
|
|
71
|
+
start with a digit, or executable is not an absolute path.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
version: str
|
|
75
|
+
executable: str
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
_check_str("version", self.version)
|
|
79
|
+
if not self.version[0].isdigit():
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"version must start with a digit, got {self.version!r}"
|
|
82
|
+
)
|
|
83
|
+
_check_abs_posix_path("executable", self.executable)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class VenvPython:
|
|
88
|
+
"""A virtual environment on the remote host, bound to the interpreter
|
|
89
|
+
that created it.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
venv_name: Name of the environment, usually the last component
|
|
93
|
+
of venv_path. No whitespace or "/" allowed.
|
|
94
|
+
python_instance: Interpreter used to create this environment.
|
|
95
|
+
venv_path: Absolute remote path to the environment,
|
|
96
|
+
e.g. "/home/app/venvs/myapp".
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
TypeError: On construction, if any field has the wrong type.
|
|
100
|
+
ValueError: On construction, if venv_name contains whitespace
|
|
101
|
+
or "/", or venv_path is not an absolute path.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
venv_name: str
|
|
105
|
+
python_instance: PythonInstance
|
|
106
|
+
venv_path: str
|
|
107
|
+
|
|
108
|
+
def __post_init__(self) -> None:
|
|
109
|
+
_check_str("venv_name", self.venv_name)
|
|
110
|
+
if "/" in self.venv_name or any(c.isspace() for c in self.venv_name):
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"venv_name must not contain '/' or whitespace, "
|
|
113
|
+
f"got {self.venv_name!r}"
|
|
114
|
+
)
|
|
115
|
+
if not isinstance(self.python_instance, PythonInstance):
|
|
116
|
+
raise TypeError(
|
|
117
|
+
"python_instance must be a PythonInstance, got "
|
|
118
|
+
f"{type(self.python_instance).__name__}"
|
|
119
|
+
)
|
|
120
|
+
_check_abs_posix_path("venv_path", self.venv_path)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class SupervisorService:
|
|
125
|
+
"""Description of a supervisord [program:x] entry.
|
|
126
|
+
|
|
127
|
+
Named fields cover the common options; the open `extra` mapping
|
|
128
|
+
accepts ANY other supervisord program option verbatim, so no
|
|
129
|
+
supervisord feature is out of reach. Values may be str, int or
|
|
130
|
+
bool (bools are rendered as "true"/"false").
|
|
131
|
+
|
|
132
|
+
Example with rotating logs and process priority::
|
|
133
|
+
|
|
134
|
+
SupervisorService(
|
|
135
|
+
name="myapp",
|
|
136
|
+
command="/home/app/venv/bin/python -m myapp",
|
|
137
|
+
extra={
|
|
138
|
+
"stdout_logfile_maxbytes": "10MB",
|
|
139
|
+
"stdout_logfile_backups": 5,
|
|
140
|
+
"stderr_logfile_maxbytes": "10MB",
|
|
141
|
+
"stderr_logfile_backups": 5,
|
|
142
|
+
"priority": 200,
|
|
143
|
+
"stopsignal": "INT",
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
Attributes:
|
|
148
|
+
name: Program name; becomes the [program:<name>] section header.
|
|
149
|
+
Must be non-empty, with no whitespace and no "]".
|
|
150
|
+
command: Command line supervisord runs,
|
|
151
|
+
e.g. "/home/app/venv/bin/python -m myapp".
|
|
152
|
+
directory: Remote working directory the process is started from.
|
|
153
|
+
None omits the line (supervisord default applies).
|
|
154
|
+
user: Unix user the process runs as. None omits the line
|
|
155
|
+
(process runs as supervisord's own user).
|
|
156
|
+
autostart: Start the program automatically when supervisord
|
|
157
|
+
starts. Defaults to True.
|
|
158
|
+
autorestart: Restart the program automatically if it exits.
|
|
159
|
+
Defaults to True.
|
|
160
|
+
stdout_logfile: stdout log destination. Any value supervisord
|
|
161
|
+
accepts: an absolute path, "AUTO", "NONE" or "syslog".
|
|
162
|
+
None omits the line (supervisord defaults to AUTO).
|
|
163
|
+
"%(program_name)s" is expanded by supervisord itself.
|
|
164
|
+
stderr_logfile: stderr log destination. Same rules as
|
|
165
|
+
stdout_logfile.
|
|
166
|
+
environment: Environment variables in supervisord syntax,
|
|
167
|
+
e.g. 'KEY="value",OTHER="x"'. None omits the line.
|
|
168
|
+
extra: Any additional [program:x] options, rendered verbatim
|
|
169
|
+
as key=value lines. Keys must not collide with the named
|
|
170
|
+
fields above (that would emit duplicate INI keys).
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
TypeError: On construction, if any field has the wrong type.
|
|
174
|
+
ValueError: On construction, if name would corrupt the INI
|
|
175
|
+
section header (empty, whitespace or "]"), or an extra key
|
|
176
|
+
collides with a named field, or an extra key is empty or
|
|
177
|
+
contains characters that would break a key=value INI line.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
name: str
|
|
181
|
+
command: str
|
|
182
|
+
directory: Optional[str] = None
|
|
183
|
+
user: Optional[str] = None
|
|
184
|
+
autostart: bool = True
|
|
185
|
+
autorestart: bool = True
|
|
186
|
+
stdout_logfile: Optional[str] = "/var/log/supervisor/%(program_name)s.log"
|
|
187
|
+
stderr_logfile: Optional[str] = "/var/log/supervisor/%(program_name)s_err.log"
|
|
188
|
+
environment: Optional[str] = None
|
|
189
|
+
extra: Mapping[str, Union[str, int, bool]] = field(default_factory=dict)
|
|
190
|
+
|
|
191
|
+
# Named fields that render as their own INI lines; extra keys must
|
|
192
|
+
# not shadow them.
|
|
193
|
+
_NAMED_KEYS = frozenset(
|
|
194
|
+
{
|
|
195
|
+
"command", "directory", "user", "autostart", "autorestart",
|
|
196
|
+
"stdout_logfile", "stderr_logfile", "environment",
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def __post_init__(self) -> None:
|
|
201
|
+
_check_str("name", self.name)
|
|
202
|
+
if any(c.isspace() for c in self.name) or "]" in self.name:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Invalid service name {self.name!r}: no whitespace or ']' "
|
|
205
|
+
"allowed (it would corrupt the INI section header)"
|
|
206
|
+
)
|
|
207
|
+
_check_str("command", self.command)
|
|
208
|
+
for opt in ("directory", "user", "stdout_logfile",
|
|
209
|
+
"stderr_logfile", "environment"):
|
|
210
|
+
value = getattr(self, opt)
|
|
211
|
+
if value is not None:
|
|
212
|
+
_check_str(opt, value)
|
|
213
|
+
_check_bool("autostart", self.autostart)
|
|
214
|
+
_check_bool("autorestart", self.autorestart)
|
|
215
|
+
|
|
216
|
+
if not isinstance(self.extra, Mapping):
|
|
217
|
+
raise TypeError(
|
|
218
|
+
f"extra must be a mapping, got {type(self.extra).__name__}"
|
|
219
|
+
)
|
|
220
|
+
for key, value in self.extra.items():
|
|
221
|
+
_check_str("extra key", key)
|
|
222
|
+
if key in self._NAMED_KEYS:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"extra key {key!r} collides with the named field "
|
|
225
|
+
f"'{key}'; set it through the field instead"
|
|
226
|
+
)
|
|
227
|
+
if "=" in key or "\n" in key or any(c.isspace() for c in key):
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"extra key {key!r} would break the key=value INI line"
|
|
230
|
+
)
|
|
231
|
+
if not isinstance(value, (str, int, bool)):
|
|
232
|
+
raise TypeError(
|
|
233
|
+
f"extra[{key!r}] must be str, int or bool, "
|
|
234
|
+
f"got {type(value).__name__}"
|
|
235
|
+
)
|
|
236
|
+
if isinstance(value, str) and "\n" in value:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"extra[{key!r}] must not contain newlines"
|
|
239
|
+
)
|
|
240
|
+
# Freeze the mapping so the dataclass is deeply immutable.
|
|
241
|
+
object.__setattr__(self, "extra", MappingProxyType(dict(self.extra)))
|
pyeasydeploy/packages.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Package installation into remote virtual environments.
|
|
2
|
+
|
|
3
|
+
All installs run inside the venv (see run_in_venv) and use 'uv pip'
|
|
4
|
+
by default for speed, falling back to plain pip on request.
|
|
5
|
+
|
|
6
|
+
The private-GitHub flow clones LOCALLY with your own git credentials
|
|
7
|
+
and uploads the result: the server never needs access to your GitHub.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from fabric import Connection
|
|
18
|
+
|
|
19
|
+
from .models import VenvPython
|
|
20
|
+
from .transfer import upload_directory
|
|
21
|
+
from .venv import run_in_venv
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _pip(use_uv: bool) -> str:
|
|
25
|
+
return "uv pip" if use_uv else "python -m pip"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_remote_tempdir(conn: Connection) -> str:
|
|
29
|
+
"""Create a unique temporary directory on the remote host.
|
|
30
|
+
|
|
31
|
+
Unique per call: concurrent deploys of the same package to the
|
|
32
|
+
same host can never collide.
|
|
33
|
+
"""
|
|
34
|
+
result = conn.run("mktemp -d /tmp/pyeasydeploy.XXXXXX", hide=True)
|
|
35
|
+
return result.stdout.strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def install_packages(
|
|
39
|
+
conn: Connection,
|
|
40
|
+
venv: VenvPython,
|
|
41
|
+
packages: List[str],
|
|
42
|
+
use_uv: bool = True,
|
|
43
|
+
verbose: bool = True,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Install packages from PyPI into the remote venv.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
conn: Connection to the remote host.
|
|
49
|
+
venv: Target virtual environment.
|
|
50
|
+
packages: Requirement specifiers, e.g. ["fastapi",
|
|
51
|
+
"uvicorn[standard]", "requests>=2.31"].
|
|
52
|
+
use_uv: Install with 'uv pip' (fast, default) instead of pip.
|
|
53
|
+
verbose: Print progress to stdout.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
TypeError: If packages is not a list of str.
|
|
57
|
+
ValueError: If packages is empty or contains blank entries.
|
|
58
|
+
"""
|
|
59
|
+
if not isinstance(packages, list) or not all(
|
|
60
|
+
isinstance(pkg, str) for pkg in packages
|
|
61
|
+
):
|
|
62
|
+
raise TypeError("packages must be a list of str")
|
|
63
|
+
if not packages or not all(pkg.strip() for pkg in packages):
|
|
64
|
+
raise ValueError("packages must be a non-empty list of non-blank specifiers")
|
|
65
|
+
|
|
66
|
+
quoted = " ".join(shlex.quote(pkg) for pkg in packages)
|
|
67
|
+
if verbose:
|
|
68
|
+
print(f"Installing packages in venv: {', '.join(packages)}")
|
|
69
|
+
run_in_venv(conn, venv, f"{_pip(use_uv)} install {quoted}",
|
|
70
|
+
verbose=False, hide=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def install_local_package(
|
|
74
|
+
conn: Connection,
|
|
75
|
+
venv: VenvPython,
|
|
76
|
+
local_package_dir: str,
|
|
77
|
+
use_uv: bool = True,
|
|
78
|
+
verbose: bool = True,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Upload a local package directory and install it into the venv.
|
|
81
|
+
|
|
82
|
+
The directory must be an installable package (pyproject.toml or
|
|
83
|
+
setup.py at its root): its declared dependencies are installed
|
|
84
|
+
automatically. It is uploaded to a unique remote temp directory,
|
|
85
|
+
installed, and the temp directory is removed even if the install
|
|
86
|
+
fails.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
conn: Connection to the remote host.
|
|
90
|
+
venv: Target virtual environment.
|
|
91
|
+
local_package_dir: Path to the local package root.
|
|
92
|
+
use_uv: Install with 'uv pip' (fast, default) instead of pip.
|
|
93
|
+
verbose: Print progress to stdout.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
FileNotFoundError: If local_package_dir does not exist (from
|
|
97
|
+
upload_directory).
|
|
98
|
+
"""
|
|
99
|
+
remote_temp = _make_remote_tempdir(conn)
|
|
100
|
+
try:
|
|
101
|
+
upload_directory(conn, local_package_dir, remote_temp, verbose=verbose)
|
|
102
|
+
if verbose:
|
|
103
|
+
print(f"Installing package from {remote_temp}")
|
|
104
|
+
run_in_venv(conn, venv,
|
|
105
|
+
f"{_pip(use_uv)} install {shlex.quote(remote_temp)}",
|
|
106
|
+
verbose=False, hide=True)
|
|
107
|
+
finally:
|
|
108
|
+
if verbose:
|
|
109
|
+
print(f"Cleaning up {remote_temp}")
|
|
110
|
+
conn.run(f"rm -rf {shlex.quote(remote_temp)}", hide=True, warn=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def install_package_from_github(
|
|
114
|
+
conn: Connection,
|
|
115
|
+
venv: VenvPython,
|
|
116
|
+
github_repo_url: str,
|
|
117
|
+
use_uv: bool = True,
|
|
118
|
+
verbose: bool = True,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Install a package from a public GitHub repository.
|
|
121
|
+
|
|
122
|
+
The REMOTE host clones the repo (it must be public, or reachable
|
|
123
|
+
from the server). For private repos, use
|
|
124
|
+
install_package_from_private_github instead.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
conn: Connection to the remote host.
|
|
128
|
+
venv: Target virtual environment.
|
|
129
|
+
github_repo_url: Repository URL, e.g.
|
|
130
|
+
"https://github.com/user/repo".
|
|
131
|
+
use_uv: Install with 'uv pip' (fast, default) instead of pip.
|
|
132
|
+
verbose: Print progress to stdout.
|
|
133
|
+
"""
|
|
134
|
+
if verbose:
|
|
135
|
+
print(f"Installing package from GitHub repo: {github_repo_url}")
|
|
136
|
+
run_in_venv(conn, venv,
|
|
137
|
+
f"{_pip(use_uv)} install {shlex.quote('git+' + github_repo_url)}",
|
|
138
|
+
verbose=False, hide=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def install_package_from_private_github(
|
|
142
|
+
conn: Connection,
|
|
143
|
+
venv: VenvPython,
|
|
144
|
+
github_repo_url: str,
|
|
145
|
+
branch: str = None,
|
|
146
|
+
use_uv: bool = True,
|
|
147
|
+
verbose: bool = True,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Install a package from a private GitHub repository.
|
|
150
|
+
|
|
151
|
+
Clones LOCALLY (with your machine's git credentials), strips the
|
|
152
|
+
.git directory, uploads the source and installs it. The server
|
|
153
|
+
never needs GitHub access, deploy keys or tokens.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
conn: Connection to the remote host.
|
|
157
|
+
venv: Target virtual environment.
|
|
158
|
+
github_repo_url: Repository URL, SSH or HTTPS form.
|
|
159
|
+
branch: Branch or tag to clone. None uses the default branch.
|
|
160
|
+
use_uv: Install with 'uv pip' (fast, default) instead of pip.
|
|
161
|
+
verbose: Print progress to stdout.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
RuntimeError: If the local 'git clone' fails (bad URL, no
|
|
165
|
+
access, branch not found...).
|
|
166
|
+
"""
|
|
167
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
168
|
+
repo_name = github_repo_url.rstrip("/").split("/")[-1].removesuffix(".git")
|
|
169
|
+
local_clone = Path(tmpdir) / repo_name
|
|
170
|
+
|
|
171
|
+
clone_cmd = ["git", "clone", "--depth", "1"]
|
|
172
|
+
if branch:
|
|
173
|
+
clone_cmd += ["--branch", branch]
|
|
174
|
+
clone_cmd += [github_repo_url, str(local_clone)]
|
|
175
|
+
|
|
176
|
+
if verbose:
|
|
177
|
+
print(f"Cloning {github_repo_url} locally...")
|
|
178
|
+
result = subprocess.run(clone_cmd, capture_output=True, text=True)
|
|
179
|
+
if result.returncode != 0:
|
|
180
|
+
raise RuntimeError(f"git clone failed: {result.stderr.strip()}")
|
|
181
|
+
|
|
182
|
+
# The .git directory is history + credentials-adjacent metadata:
|
|
183
|
+
# the server needs neither.
|
|
184
|
+
shutil.rmtree(local_clone / ".git", ignore_errors=True)
|
|
185
|
+
|
|
186
|
+
install_local_package(conn, venv, str(local_clone),
|
|
187
|
+
use_uv=use_uv, verbose=verbose)
|
pyeasydeploy/py.typed
ADDED
|
File without changes
|