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.
@@ -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)))
@@ -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