mmrelay 1.2.1__py3-none-any.whl → 1.2.3__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.
Potentially problematic release.
This version of mmrelay might be problematic. Click here for more details.
- mmrelay/__init__.py +1 -1
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +451 -48
- mmrelay/cli_utils.py +59 -9
- mmrelay/config.py +193 -66
- mmrelay/constants/app.py +2 -2
- mmrelay/db_utils.py +73 -26
- mmrelay/e2ee_utils.py +11 -3
- mmrelay/log_utils.py +16 -5
- mmrelay/main.py +41 -38
- mmrelay/matrix_utils.py +1068 -292
- mmrelay/meshtastic_utils.py +352 -209
- mmrelay/message_queue.py +22 -23
- mmrelay/plugin_loader.py +634 -205
- mmrelay/plugins/mesh_relay_plugin.py +44 -38
- mmrelay/plugins/weather_plugin.py +11 -12
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +323 -128
- mmrelay/tools/mmrelay.service +2 -1
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +11 -72
- mmrelay/tools/sample-docker-compose.yaml +12 -58
- mmrelay/tools/sample_config.yaml +1 -1
- mmrelay/windows_utils.py +349 -0
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/METADATA +7 -7
- mmrelay-1.2.3.dist-info/RECORD +48 -0
- mmrelay-1.2.1.dist-info/RECORD +0 -45
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/WHEEL +0 -0
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/top_level.txt +0 -0
mmrelay/plugin_loader.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# trunk-ignore-all(bandit)
|
|
2
2
|
import hashlib
|
|
3
|
+
import importlib
|
|
3
4
|
import importlib.util
|
|
4
5
|
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import site
|
|
5
10
|
import subprocess
|
|
6
11
|
import sys
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from typing import List, Set
|
|
7
14
|
|
|
8
15
|
from mmrelay.config import get_app_path, get_base_dir
|
|
9
16
|
from mmrelay.log_utils import get_logger
|
|
@@ -16,6 +23,147 @@ sorted_active_plugins = []
|
|
|
16
23
|
plugins_loaded = False
|
|
17
24
|
|
|
18
25
|
|
|
26
|
+
try:
|
|
27
|
+
_PLUGIN_DEPS_DIR = os.path.join(get_base_dir(), "plugins", "deps")
|
|
28
|
+
except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover
|
|
29
|
+
logger.debug("Unable to resolve base dir for plugin deps at import time: %s", exc)
|
|
30
|
+
_PLUGIN_DEPS_DIR = None
|
|
31
|
+
else:
|
|
32
|
+
try:
|
|
33
|
+
os.makedirs(_PLUGIN_DEPS_DIR, exist_ok=True)
|
|
34
|
+
except OSError as exc: # pragma: no cover - logging only in unusual environments
|
|
35
|
+
logger.debug(
|
|
36
|
+
f"Unable to create plugin dependency directory '{_PLUGIN_DEPS_DIR}': {exc}"
|
|
37
|
+
)
|
|
38
|
+
_PLUGIN_DEPS_DIR = None
|
|
39
|
+
else:
|
|
40
|
+
deps_path = os.fspath(_PLUGIN_DEPS_DIR)
|
|
41
|
+
if deps_path not in sys.path:
|
|
42
|
+
sys.path.append(deps_path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _collect_requirements(
|
|
46
|
+
requirements_file: str, visited: Set[str] | None = None
|
|
47
|
+
) -> List[str]:
|
|
48
|
+
"""
|
|
49
|
+
Parse a requirements.txt file and return a flattened list of installable requirement tokens.
|
|
50
|
+
|
|
51
|
+
The function reads the given requirements file, ignores blank lines and comments (including inline
|
|
52
|
+
comments after " #"), and resolves nested includes and constraint files. Supported include forms:
|
|
53
|
+
- "-r <file>" or "--requirement <file>"
|
|
54
|
+
- "-c <file>" or "--constraint <file>"
|
|
55
|
+
- "--requirement=<file>" and "--constraint=<file>"
|
|
56
|
+
|
|
57
|
+
Lines beginning with "-" are tokenized with shlex.split (posix mode) so flags and compound entries
|
|
58
|
+
are preserved; other non-directive lines are returned verbatim.
|
|
59
|
+
|
|
60
|
+
Parameters:
|
|
61
|
+
requirements_file (str): Path to a requirements file. Relative includes are resolved
|
|
62
|
+
relative to this file's directory.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List[str]: A flattened list of requirement tokens suitable for passing to pip.
|
|
66
|
+
Returns an empty list if the file does not exist or if recursion is detected for a nested include.
|
|
67
|
+
|
|
68
|
+
Notes:
|
|
69
|
+
- The optional `visited` parameter (used internally) tracks normalized file paths to detect
|
|
70
|
+
and prevent recursive includes; recursion results in a logged warning and the include is skipped.
|
|
71
|
+
- The function logs warnings for missing files and malformed include/constraint directives but
|
|
72
|
+
does not raise exceptions for those conditions.
|
|
73
|
+
"""
|
|
74
|
+
normalized_path = os.path.abspath(requirements_file)
|
|
75
|
+
visited = visited or set()
|
|
76
|
+
|
|
77
|
+
if normalized_path in visited:
|
|
78
|
+
logger.warning(
|
|
79
|
+
"Requirements file recursion detected for %s; skipping duplicate include.",
|
|
80
|
+
normalized_path,
|
|
81
|
+
)
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
visited.add(normalized_path)
|
|
85
|
+
requirements: List[str] = []
|
|
86
|
+
base_dir = os.path.dirname(normalized_path)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
with open(normalized_path, encoding="utf-8") as handle:
|
|
90
|
+
for raw_line in handle:
|
|
91
|
+
line = raw_line.strip()
|
|
92
|
+
if not line or line.startswith("#"):
|
|
93
|
+
continue
|
|
94
|
+
if " #" in line:
|
|
95
|
+
line = line.split(" #", 1)[0].strip()
|
|
96
|
+
if not line:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
lower_line = line.lower()
|
|
100
|
+
|
|
101
|
+
def _resolve_nested(path_str: str) -> None:
|
|
102
|
+
nested_path = (
|
|
103
|
+
path_str
|
|
104
|
+
if os.path.isabs(path_str)
|
|
105
|
+
else os.path.join(base_dir, path_str)
|
|
106
|
+
)
|
|
107
|
+
requirements.extend(
|
|
108
|
+
_collect_requirements(nested_path, visited=visited)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
is_req_eq = lower_line.startswith("--requirement=")
|
|
112
|
+
is_con_eq = lower_line.startswith("--constraint=")
|
|
113
|
+
|
|
114
|
+
if is_req_eq or is_con_eq:
|
|
115
|
+
nested = line.split("=", 1)[1].strip()
|
|
116
|
+
_resolve_nested(nested)
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
is_req = lower_line.startswith(("-r ", "--requirement "))
|
|
120
|
+
is_con = lower_line.startswith(("-c ", "--constraint "))
|
|
121
|
+
|
|
122
|
+
if is_req or is_con:
|
|
123
|
+
parts = line.split(None, 1)
|
|
124
|
+
if len(parts) == 2:
|
|
125
|
+
_resolve_nested(parts[1].strip())
|
|
126
|
+
else:
|
|
127
|
+
directive_type = (
|
|
128
|
+
"requirement include" if is_req else "constraint"
|
|
129
|
+
)
|
|
130
|
+
logger.warning(
|
|
131
|
+
"Ignoring malformed %s directive in %s: %s",
|
|
132
|
+
directive_type,
|
|
133
|
+
normalized_path,
|
|
134
|
+
raw_line.rstrip(),
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if line.startswith("-"):
|
|
139
|
+
requirements.extend(shlex.split(line, posix=True))
|
|
140
|
+
else:
|
|
141
|
+
requirements.append(line)
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
logger.warning("Requirements file not found: %s", normalized_path)
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
return requirements
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@contextmanager
|
|
150
|
+
def _temp_sys_path(path: str):
|
|
151
|
+
"""
|
|
152
|
+
Context manager that temporarily prepends a directory to Python's import search path.
|
|
153
|
+
|
|
154
|
+
Use as: `with _temp_sys_path(path): ...` — the given `path` is inserted at the front of `sys.path` for the duration of the context. On exit the first occurrence of `path` is removed; if the path is already absent, removal is silently ignored.
|
|
155
|
+
"""
|
|
156
|
+
path = os.fspath(path)
|
|
157
|
+
sys.path.insert(0, path)
|
|
158
|
+
try:
|
|
159
|
+
yield
|
|
160
|
+
finally:
|
|
161
|
+
try:
|
|
162
|
+
sys.path.remove(path)
|
|
163
|
+
except ValueError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
19
167
|
def _reset_caches_for_tests():
|
|
20
168
|
"""
|
|
21
169
|
Reset the global plugin loader caches to their initial state for testing purposes.
|
|
@@ -27,15 +175,171 @@ def _reset_caches_for_tests():
|
|
|
27
175
|
plugins_loaded = False
|
|
28
176
|
|
|
29
177
|
|
|
178
|
+
def _refresh_dependency_paths() -> None:
|
|
179
|
+
"""
|
|
180
|
+
Ensure packages installed into user or site directories become importable.
|
|
181
|
+
|
|
182
|
+
This function collects candidate site paths from site.getusersitepackages() and
|
|
183
|
+
site.getsitepackages() (when available), and registers each directory with the
|
|
184
|
+
import system. It prefers site.addsitedir(path) but falls back to appending the
|
|
185
|
+
path to sys.path if addsitedir fails. After modifying the import paths it calls
|
|
186
|
+
importlib.invalidate_caches() so newly installed packages are discoverable.
|
|
187
|
+
|
|
188
|
+
Side effects:
|
|
189
|
+
- May modify sys.path and the interpreter's site directories.
|
|
190
|
+
- Calls importlib.invalidate_caches() to refresh import machinery.
|
|
191
|
+
- Logs warnings if adding a directory via site.addsitedir fails.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
candidate_paths = []
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
user_site = site.getusersitepackages()
|
|
198
|
+
if isinstance(user_site, str):
|
|
199
|
+
candidate_paths.append(user_site)
|
|
200
|
+
else:
|
|
201
|
+
candidate_paths.extend(user_site)
|
|
202
|
+
except AttributeError:
|
|
203
|
+
logger.debug("site.getusersitepackages() not available in this environment.")
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
site_packages = site.getsitepackages()
|
|
207
|
+
candidate_paths.extend(site_packages)
|
|
208
|
+
except AttributeError:
|
|
209
|
+
logger.debug("site.getsitepackages() not available in this environment.")
|
|
210
|
+
|
|
211
|
+
if _PLUGIN_DEPS_DIR:
|
|
212
|
+
candidate_paths.append(os.fspath(_PLUGIN_DEPS_DIR))
|
|
213
|
+
|
|
214
|
+
for path in dict.fromkeys(candidate_paths): # dedupe while preserving order
|
|
215
|
+
if not path:
|
|
216
|
+
continue
|
|
217
|
+
if path not in sys.path:
|
|
218
|
+
try:
|
|
219
|
+
site.addsitedir(path)
|
|
220
|
+
except OSError as e:
|
|
221
|
+
logger.warning(
|
|
222
|
+
f"site.addsitedir failed for '{path}': {e}. Falling back to sys.path.insert(0, ...)."
|
|
223
|
+
)
|
|
224
|
+
sys.path.insert(0, path)
|
|
225
|
+
|
|
226
|
+
# Ensure import machinery notices new packages
|
|
227
|
+
importlib.invalidate_caches()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _install_requirements_for_repo(repo_path: str, repo_name: str) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Install Python dependencies for a community plugin repository from a requirements.txt file.
|
|
233
|
+
|
|
234
|
+
If a requirements.txt file exists at repo_path, this function will attempt to install the listed
|
|
235
|
+
dependencies and then refresh interpreter import paths so newly installed packages become importable.
|
|
236
|
+
|
|
237
|
+
Behavior highlights:
|
|
238
|
+
- No-op if requirements.txt is missing or empty.
|
|
239
|
+
- Respects the global auto-install configuration; if auto-install is disabled, the function logs and returns.
|
|
240
|
+
- In a pipx-managed environment (detected via PIPX_* env vars) it uses `pipx inject mmrelay ...` to
|
|
241
|
+
add dependencies to the application's pipx venv.
|
|
242
|
+
- Otherwise it uses `python -m pip install -r requirements.txt` and adds `--user` when not running
|
|
243
|
+
inside a virtual environment.
|
|
244
|
+
- After a successful install it calls the path refresh routine so the interpreter can import newly
|
|
245
|
+
installed packages.
|
|
246
|
+
|
|
247
|
+
Parameters that need extra context:
|
|
248
|
+
- repo_path: filesystem path to the plugin repository directory (the function looks for
|
|
249
|
+
repo_path/requirements.txt).
|
|
250
|
+
- repo_name: human-readable repository name used in log messages.
|
|
251
|
+
|
|
252
|
+
Side effects:
|
|
253
|
+
- Installs packages (via pipx or pip) and updates interpreter import paths.
|
|
254
|
+
- Logs on success or failure; on installation failure it logs an exception and a warning that the
|
|
255
|
+
plugin may not work correctly without its dependencies.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
requirements_path = os.path.join(repo_path, "requirements.txt")
|
|
259
|
+
if not os.path.isfile(requirements_path):
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
if not _check_auto_install_enabled(config):
|
|
263
|
+
logger.warning(
|
|
264
|
+
"Auto-install of requirements for %s disabled by config; skipping.",
|
|
265
|
+
repo_name,
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
in_pipx = any(
|
|
271
|
+
key in os.environ
|
|
272
|
+
for key in ("PIPX_HOME", "PIPX_LOCAL_VENVS", "PIPX_BIN_DIR")
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if in_pipx:
|
|
276
|
+
logger.info("Installing requirements for plugin %s with pipx", repo_name)
|
|
277
|
+
pipx_path = shutil.which("pipx")
|
|
278
|
+
if not pipx_path:
|
|
279
|
+
raise FileNotFoundError("pipx executable not found on PATH")
|
|
280
|
+
requirements = _collect_requirements(requirements_path)
|
|
281
|
+
if requirements:
|
|
282
|
+
packages = [r for r in requirements if not r.startswith("-")]
|
|
283
|
+
pip_args = [r for r in requirements if r.startswith("-")]
|
|
284
|
+
if not packages:
|
|
285
|
+
logger.info(
|
|
286
|
+
"Requirements in %s only contained pip flags; skipping pipx injection.",
|
|
287
|
+
requirements_path,
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
cmd = [pipx_path, "inject", "mmrelay", *packages]
|
|
291
|
+
if pip_args:
|
|
292
|
+
cmd += ["--pip-args", " ".join(pip_args)]
|
|
293
|
+
_run(cmd, timeout=600)
|
|
294
|
+
else:
|
|
295
|
+
logger.info(
|
|
296
|
+
"No dependencies listed in %s; skipping pipx injection.",
|
|
297
|
+
requirements_path,
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
in_venv = (sys.prefix != getattr(sys, "base_prefix", sys.prefix)) or (
|
|
301
|
+
"VIRTUAL_ENV" in os.environ
|
|
302
|
+
)
|
|
303
|
+
logger.info("Installing requirements for plugin %s with pip", repo_name)
|
|
304
|
+
cmd = [
|
|
305
|
+
sys.executable,
|
|
306
|
+
"-m",
|
|
307
|
+
"pip",
|
|
308
|
+
"install",
|
|
309
|
+
"-r",
|
|
310
|
+
requirements_path,
|
|
311
|
+
"--disable-pip-version-check",
|
|
312
|
+
"--no-input",
|
|
313
|
+
]
|
|
314
|
+
if not in_venv:
|
|
315
|
+
cmd.append("--user")
|
|
316
|
+
_run(cmd, timeout=600)
|
|
317
|
+
|
|
318
|
+
logger.info("Successfully installed requirements for plugin %s", repo_name)
|
|
319
|
+
_refresh_dependency_paths()
|
|
320
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
321
|
+
logger.exception(
|
|
322
|
+
"Error installing requirements for plugin %s (requirements: %s)",
|
|
323
|
+
repo_name,
|
|
324
|
+
requirements_path,
|
|
325
|
+
)
|
|
326
|
+
logger.warning(
|
|
327
|
+
"Plugin %s may not work correctly without its dependencies",
|
|
328
|
+
repo_name,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
30
332
|
def _get_plugin_dirs(plugin_type):
|
|
31
333
|
"""
|
|
32
|
-
Return a prioritized list of directories for the
|
|
334
|
+
Return a prioritized list of existing plugin directories for the given plugin type.
|
|
335
|
+
|
|
336
|
+
Attempts to ensure and prefer a per-user plugins directory (base_dir/plugins/<type>) and also includes a local application plugins directory (app_path/plugins/<type>) for backward compatibility. Each directory is created if possible; directories that cannot be created or accessed are omitted from the result.
|
|
33
337
|
|
|
34
338
|
Parameters:
|
|
35
|
-
plugin_type (str):
|
|
339
|
+
plugin_type (str): Plugin category, e.g. "custom" or "community".
|
|
36
340
|
|
|
37
341
|
Returns:
|
|
38
|
-
list:
|
|
342
|
+
list[str]: Ordered list of plugin directories to search (user directory first when available, then local directory).
|
|
39
343
|
"""
|
|
40
344
|
dirs = []
|
|
41
345
|
|
|
@@ -77,22 +381,116 @@ def get_community_plugin_dirs():
|
|
|
77
381
|
return _get_plugin_dirs("community")
|
|
78
382
|
|
|
79
383
|
|
|
384
|
+
def _run(cmd, timeout=120, **kwargs):
|
|
385
|
+
# Validate command to prevent shell injection
|
|
386
|
+
"""
|
|
387
|
+
Run a subprocess command safely with validated arguments and a configurable timeout.
|
|
388
|
+
|
|
389
|
+
Validates that `cmd` is a non-empty list of non-empty strings (to avoid shell-injection risks),
|
|
390
|
+
ensures text output by default, and executes the command via subprocess.run with check=True.
|
|
391
|
+
|
|
392
|
+
Parameters:
|
|
393
|
+
cmd (list[str]): Command and arguments to execute; must be a non-empty list of non-empty strings.
|
|
394
|
+
timeout (int|float): Maximum seconds to allow the process to run before raising TimeoutExpired.
|
|
395
|
+
**kwargs: Additional keyword arguments forwarded to subprocess.run (e.g., cwd, env). `text=True`
|
|
396
|
+
is set by default if not provided.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
subprocess.CompletedProcess: The completed process object returned by subprocess.run.
|
|
400
|
+
|
|
401
|
+
Raises:
|
|
402
|
+
TypeError: If `cmd` is not a list or any element of `cmd` is not a string.
|
|
403
|
+
ValueError: If `cmd` is empty or contains empty/whitespace-only arguments.
|
|
404
|
+
subprocess.CalledProcessError: If the subprocess exits with a non-zero status (check=True).
|
|
405
|
+
subprocess.TimeoutExpired: If the process exceeds the specified timeout.
|
|
406
|
+
"""
|
|
407
|
+
if not isinstance(cmd, list):
|
|
408
|
+
raise TypeError("cmd must be a list of str")
|
|
409
|
+
if not cmd:
|
|
410
|
+
raise ValueError("Command list cannot be empty")
|
|
411
|
+
if not all(isinstance(arg, str) for arg in cmd):
|
|
412
|
+
raise TypeError("all command arguments must be strings")
|
|
413
|
+
if any(not arg.strip() for arg in cmd):
|
|
414
|
+
raise ValueError("command arguments cannot be empty/whitespace")
|
|
415
|
+
if kwargs.get("shell"):
|
|
416
|
+
raise ValueError("shell=True is not allowed in _run")
|
|
417
|
+
# Ensure text mode by default
|
|
418
|
+
kwargs.setdefault("text", True)
|
|
419
|
+
return subprocess.run(cmd, check=True, timeout=timeout, **kwargs)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _check_auto_install_enabled(config):
|
|
423
|
+
"""
|
|
424
|
+
Return whether automatic dependency installation is enabled.
|
|
425
|
+
|
|
426
|
+
Reads the value at config["security"]["auto_install_deps"] and returns its truthiness.
|
|
427
|
+
If `config` is None or falsy, or the key is missing, this function returns True (auto-install enabled by default).
|
|
428
|
+
"""
|
|
429
|
+
if not config:
|
|
430
|
+
return True
|
|
431
|
+
return bool(config.get("security", {}).get("auto_install_deps", True))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _raise_install_error(pkg_name):
|
|
435
|
+
"""
|
|
436
|
+
Log a warning about disabled auto-install and raise a CalledProcessError.
|
|
437
|
+
|
|
438
|
+
Parameters:
|
|
439
|
+
pkg_name (str): Name of the package that could not be installed (used in the log message).
|
|
440
|
+
|
|
441
|
+
Raises:
|
|
442
|
+
subprocess.CalledProcessError: Always raised to signal an installation failure when auto-install is disabled.
|
|
443
|
+
"""
|
|
444
|
+
logger.warning(
|
|
445
|
+
f"Auto-install disabled; cannot install {pkg_name}. See docs for enabling."
|
|
446
|
+
)
|
|
447
|
+
raise subprocess.CalledProcessError(1, "pip/pipx")
|
|
448
|
+
|
|
449
|
+
|
|
80
450
|
def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
81
451
|
"""
|
|
82
|
-
Clone or update a community plugin Git repository
|
|
452
|
+
Clone or update a community plugin Git repository.
|
|
83
453
|
|
|
84
|
-
|
|
454
|
+
Performs a best-effort clone or update of the repository at repo_url into
|
|
455
|
+
plugins_dir/repo_name using the provided ref (a dict with keys "type"
|
|
456
|
+
("tag" or "branch") and "value" (name)). If the repository already exists,
|
|
457
|
+
the function attempts to fetch and switch to the requested branch or tag,
|
|
458
|
+
with fallbacks to common default branches ("main", "master") when
|
|
459
|
+
appropriate.
|
|
85
460
|
|
|
86
461
|
Parameters:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
-
|
|
90
|
-
- value: The tag or branch name to use.
|
|
91
|
-
plugins_dir (str): Directory where the repository should be cloned or updated.
|
|
462
|
+
ref (dict): Reference spec with keys:
|
|
463
|
+
- type: either "tag" or "branch".
|
|
464
|
+
- value: the tag or branch name to check out.
|
|
92
465
|
|
|
93
466
|
Returns:
|
|
94
|
-
bool: True if the repository was successfully cloned
|
|
467
|
+
bool: True if the repository was successfully cloned/updated; False if a fatal git or filesystem error prevented cloning or updating.
|
|
95
468
|
"""
|
|
469
|
+
repo_url = (repo_url or "").strip()
|
|
470
|
+
ref_type = ref.get("type") # expected: "tag" or "branch"
|
|
471
|
+
ref_value = (ref.get("value") or "").strip()
|
|
472
|
+
|
|
473
|
+
if not repo_url or repo_url.startswith("-"):
|
|
474
|
+
logger.error("Repository URL looks invalid or dangerous: %r", repo_url)
|
|
475
|
+
return False
|
|
476
|
+
allowed_ref_types = {"tag", "branch"}
|
|
477
|
+
if ref_type not in allowed_ref_types:
|
|
478
|
+
logger.error(
|
|
479
|
+
"Invalid ref type %r (expected 'tag' or 'branch') for %r",
|
|
480
|
+
ref_type,
|
|
481
|
+
repo_url,
|
|
482
|
+
)
|
|
483
|
+
return False
|
|
484
|
+
if not ref_value:
|
|
485
|
+
logger.error("Missing ref value for %s on %r", ref_type, repo_url)
|
|
486
|
+
return False
|
|
487
|
+
if ref_value.startswith("-"):
|
|
488
|
+
logger.error("Ref value looks invalid (starts with '-'): %r", ref_value)
|
|
489
|
+
return False
|
|
490
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", ref_value):
|
|
491
|
+
logger.error("Invalid %s name supplied: %r", ref_type, ref_value)
|
|
492
|
+
return False
|
|
493
|
+
|
|
96
494
|
# Extract the repository name from the URL
|
|
97
495
|
repo_name = os.path.splitext(os.path.basename(repo_url.rstrip("/")))[0]
|
|
98
496
|
repo_path = os.path.join(plugins_dir, repo_name)
|
|
@@ -100,10 +498,6 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
100
498
|
# Default branch names to try if ref is not specified
|
|
101
499
|
default_branches = ["main", "master"]
|
|
102
500
|
|
|
103
|
-
# Get the ref type and value
|
|
104
|
-
ref_type = ref["type"] # "tag" or "branch"
|
|
105
|
-
ref_value = ref["value"]
|
|
106
|
-
|
|
107
501
|
# Log what we're trying to do
|
|
108
502
|
logger.info(f"Using {ref_type} '{ref_value}' for repository {repo_name}")
|
|
109
503
|
|
|
@@ -114,7 +508,7 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
114
508
|
try:
|
|
115
509
|
# Fetch all branches but don't fetch tags to avoid conflicts
|
|
116
510
|
try:
|
|
117
|
-
|
|
511
|
+
_run(["git", "-C", repo_path, "fetch", "origin"], timeout=120)
|
|
118
512
|
except subprocess.CalledProcessError as e:
|
|
119
513
|
logger.warning(f"Error fetching from remote: {e}")
|
|
120
514
|
# Continue anyway, we'll try to use what we have
|
|
@@ -123,16 +517,17 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
123
517
|
if is_default_branch:
|
|
124
518
|
try:
|
|
125
519
|
# Check if we're already on the right branch
|
|
126
|
-
current_branch =
|
|
520
|
+
current_branch = _run(
|
|
127
521
|
["git", "-C", repo_path, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
128
|
-
|
|
129
|
-
).strip()
|
|
522
|
+
capture_output=True,
|
|
523
|
+
).stdout.strip()
|
|
130
524
|
|
|
131
525
|
if current_branch == ref_value:
|
|
132
526
|
# We're on the right branch, just pull
|
|
133
527
|
try:
|
|
134
|
-
|
|
135
|
-
["git", "-C", repo_path, "pull", "origin", ref_value]
|
|
528
|
+
_run(
|
|
529
|
+
["git", "-C", repo_path, "pull", "origin", ref_value],
|
|
530
|
+
timeout=120,
|
|
136
531
|
)
|
|
137
532
|
logger.info(
|
|
138
533
|
f"Updated repository {repo_name} branch {ref_value}"
|
|
@@ -144,11 +539,13 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
144
539
|
return True
|
|
145
540
|
else:
|
|
146
541
|
# Switch to the right branch
|
|
147
|
-
|
|
148
|
-
["git", "-C", repo_path, "checkout", ref_value]
|
|
542
|
+
_run(
|
|
543
|
+
["git", "-C", repo_path, "checkout", ref_value],
|
|
544
|
+
timeout=120,
|
|
149
545
|
)
|
|
150
|
-
|
|
151
|
-
["git", "-C", repo_path, "pull", "origin", ref_value]
|
|
546
|
+
_run(
|
|
547
|
+
["git", "-C", repo_path, "pull", "origin", ref_value],
|
|
548
|
+
timeout=120,
|
|
152
549
|
)
|
|
153
550
|
if ref_type == "branch":
|
|
154
551
|
logger.info(f"Switched to and updated branch {ref_value}")
|
|
@@ -162,11 +559,13 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
162
559
|
logger.warning(
|
|
163
560
|
f"Branch {ref_value} not found, trying {other_default}"
|
|
164
561
|
)
|
|
165
|
-
|
|
166
|
-
["git", "-C", repo_path, "checkout", other_default]
|
|
562
|
+
_run(
|
|
563
|
+
["git", "-C", repo_path, "checkout", other_default],
|
|
564
|
+
timeout=120,
|
|
167
565
|
)
|
|
168
|
-
|
|
169
|
-
["git", "-C", repo_path, "pull", "origin", other_default]
|
|
566
|
+
_run(
|
|
567
|
+
["git", "-C", repo_path, "pull", "origin", other_default],
|
|
568
|
+
timeout=120,
|
|
170
569
|
)
|
|
171
570
|
logger.info(
|
|
172
571
|
f"Using {other_default} branch instead of {ref_value}"
|
|
@@ -179,22 +578,45 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
179
578
|
)
|
|
180
579
|
return True
|
|
181
580
|
else:
|
|
581
|
+
if ref_type == "branch":
|
|
582
|
+
try:
|
|
583
|
+
_run(
|
|
584
|
+
["git", "-C", repo_path, "checkout", ref_value],
|
|
585
|
+
timeout=120,
|
|
586
|
+
)
|
|
587
|
+
_run(
|
|
588
|
+
["git", "-C", repo_path, "pull", "origin", ref_value],
|
|
589
|
+
timeout=120,
|
|
590
|
+
)
|
|
591
|
+
logger.info(
|
|
592
|
+
f"Updated repository {repo_name} to branch {ref_value}"
|
|
593
|
+
)
|
|
594
|
+
return True
|
|
595
|
+
except subprocess.CalledProcessError as exc:
|
|
596
|
+
logger.warning(
|
|
597
|
+
"Failed to update branch %s for %s: %s",
|
|
598
|
+
ref_value,
|
|
599
|
+
repo_name,
|
|
600
|
+
exc,
|
|
601
|
+
)
|
|
602
|
+
return False
|
|
603
|
+
|
|
182
604
|
# Handle tag checkout
|
|
183
605
|
# Check if we're already on the correct tag/commit
|
|
184
606
|
try:
|
|
185
607
|
# Get the current commit hash
|
|
186
|
-
current_commit =
|
|
608
|
+
current_commit = _run(
|
|
187
609
|
["git", "-C", repo_path, "rev-parse", "HEAD"],
|
|
188
|
-
|
|
189
|
-
).strip()
|
|
610
|
+
capture_output=True,
|
|
611
|
+
).stdout.strip()
|
|
190
612
|
|
|
191
613
|
# Get the commit hash for the tag
|
|
192
614
|
tag_commit = None
|
|
193
615
|
try:
|
|
194
|
-
tag_commit =
|
|
616
|
+
tag_commit = _run(
|
|
195
617
|
["git", "-C", repo_path, "rev-parse", ref_value],
|
|
196
|
-
|
|
197
|
-
).strip()
|
|
618
|
+
capture_output=True,
|
|
619
|
+
).stdout.strip()
|
|
198
620
|
except subprocess.CalledProcessError:
|
|
199
621
|
# Tag doesn't exist locally, we'll need to fetch it
|
|
200
622
|
pass
|
|
@@ -206,18 +628,12 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
206
628
|
)
|
|
207
629
|
return True
|
|
208
630
|
|
|
209
|
-
# Otherwise, try to checkout the tag
|
|
210
|
-
|
|
211
|
-
["git", "-C", repo_path, "checkout", ref_value]
|
|
631
|
+
# Otherwise, try to checkout the tag or branch
|
|
632
|
+
_run(
|
|
633
|
+
["git", "-C", repo_path, "checkout", ref_value],
|
|
634
|
+
timeout=120,
|
|
212
635
|
)
|
|
213
|
-
|
|
214
|
-
logger.info(
|
|
215
|
-
f"Updated repository {repo_name} to branch {ref_value}"
|
|
216
|
-
)
|
|
217
|
-
else:
|
|
218
|
-
logger.info(
|
|
219
|
-
f"Updated repository {repo_name} to tag {ref_value}"
|
|
220
|
-
)
|
|
636
|
+
logger.info(f"Updated repository {repo_name} to tag {ref_value}")
|
|
221
637
|
return True
|
|
222
638
|
except subprocess.CalledProcessError:
|
|
223
639
|
# If tag checkout fails, try to fetch it specifically
|
|
@@ -228,8 +644,9 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
228
644
|
# Try to fetch the specific tag, but first remove any existing tag with the same name
|
|
229
645
|
try:
|
|
230
646
|
# Delete the local tag if it exists to avoid conflicts
|
|
231
|
-
|
|
232
|
-
["git", "-C", repo_path, "tag", "-d", ref_value]
|
|
647
|
+
_run(
|
|
648
|
+
["git", "-C", repo_path, "tag", "-d", ref_value],
|
|
649
|
+
timeout=120,
|
|
233
650
|
)
|
|
234
651
|
except subprocess.CalledProcessError:
|
|
235
652
|
# Tag doesn't exist locally, which is fine
|
|
@@ -238,7 +655,7 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
238
655
|
# Now fetch the tag from remote
|
|
239
656
|
try:
|
|
240
657
|
# Try to fetch the tag
|
|
241
|
-
|
|
658
|
+
_run(
|
|
242
659
|
[
|
|
243
660
|
"git",
|
|
244
661
|
"-C",
|
|
@@ -246,11 +663,12 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
246
663
|
"fetch",
|
|
247
664
|
"origin",
|
|
248
665
|
f"refs/tags/{ref_value}",
|
|
249
|
-
]
|
|
666
|
+
],
|
|
667
|
+
timeout=120,
|
|
250
668
|
)
|
|
251
669
|
except subprocess.CalledProcessError:
|
|
252
670
|
# If that fails, try to fetch the tag without the refs/tags/ prefix
|
|
253
|
-
|
|
671
|
+
_run(
|
|
254
672
|
[
|
|
255
673
|
"git",
|
|
256
674
|
"-C",
|
|
@@ -258,20 +676,17 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
258
676
|
"fetch",
|
|
259
677
|
"origin",
|
|
260
678
|
f"refs/tags/{ref_value}:refs/tags/{ref_value}",
|
|
261
|
-
]
|
|
679
|
+
],
|
|
680
|
+
timeout=120,
|
|
262
681
|
)
|
|
263
682
|
|
|
264
|
-
|
|
265
|
-
["git", "-C", repo_path, "checkout", ref_value]
|
|
683
|
+
_run(
|
|
684
|
+
["git", "-C", repo_path, "checkout", ref_value],
|
|
685
|
+
timeout=120,
|
|
686
|
+
)
|
|
687
|
+
logger.info(
|
|
688
|
+
f"Successfully fetched and checked out tag {ref_value}"
|
|
266
689
|
)
|
|
267
|
-
if ref_type == "branch":
|
|
268
|
-
logger.info(
|
|
269
|
-
f"Successfully fetched and checked out branch {ref_value}"
|
|
270
|
-
)
|
|
271
|
-
else:
|
|
272
|
-
logger.info(
|
|
273
|
-
f"Successfully fetched and checked out tag {ref_value}"
|
|
274
|
-
)
|
|
275
690
|
return True
|
|
276
691
|
except subprocess.CalledProcessError:
|
|
277
692
|
# If that fails too, try as a branch
|
|
@@ -279,14 +694,17 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
279
694
|
f"Could not fetch tag {ref_value}, trying as a branch"
|
|
280
695
|
)
|
|
281
696
|
try:
|
|
282
|
-
|
|
283
|
-
["git", "-C", repo_path, "fetch", "origin", ref_value]
|
|
697
|
+
_run(
|
|
698
|
+
["git", "-C", repo_path, "fetch", "origin", ref_value],
|
|
699
|
+
timeout=120,
|
|
284
700
|
)
|
|
285
|
-
|
|
286
|
-
["git", "-C", repo_path, "checkout", ref_value]
|
|
701
|
+
_run(
|
|
702
|
+
["git", "-C", repo_path, "checkout", ref_value],
|
|
703
|
+
timeout=120,
|
|
287
704
|
)
|
|
288
|
-
|
|
289
|
-
["git", "-C", repo_path, "pull", "origin", ref_value]
|
|
705
|
+
_run(
|
|
706
|
+
["git", "-C", repo_path, "pull", "origin", ref_value],
|
|
707
|
+
timeout=120,
|
|
290
708
|
)
|
|
291
709
|
logger.info(
|
|
292
710
|
f"Updated repository {repo_name} to branch {ref_value}"
|
|
@@ -299,16 +717,17 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
299
717
|
)
|
|
300
718
|
for default_branch in default_branches:
|
|
301
719
|
try:
|
|
302
|
-
|
|
720
|
+
_run(
|
|
303
721
|
[
|
|
304
722
|
"git",
|
|
305
723
|
"-C",
|
|
306
724
|
repo_path,
|
|
307
725
|
"checkout",
|
|
308
726
|
default_branch,
|
|
309
|
-
]
|
|
727
|
+
],
|
|
728
|
+
timeout=120,
|
|
310
729
|
)
|
|
311
|
-
|
|
730
|
+
_run(
|
|
312
731
|
[
|
|
313
732
|
"git",
|
|
314
733
|
"-C",
|
|
@@ -316,7 +735,8 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
316
735
|
"pull",
|
|
317
736
|
"origin",
|
|
318
737
|
default_branch,
|
|
319
|
-
]
|
|
738
|
+
],
|
|
739
|
+
timeout=120,
|
|
320
740
|
)
|
|
321
741
|
logger.info(
|
|
322
742
|
f"Using {default_branch} instead of {ref_value}"
|
|
@@ -330,7 +750,7 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
330
750
|
"Could not checkout any branch, using current state"
|
|
331
751
|
)
|
|
332
752
|
return True
|
|
333
|
-
except subprocess.CalledProcessError as e:
|
|
753
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
334
754
|
logger.error(f"Error updating repository {repo_name}: {e}")
|
|
335
755
|
logger.error(
|
|
336
756
|
f"Please manually git clone the repository {repo_url} into {repo_path}"
|
|
@@ -340,8 +760,8 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
340
760
|
# Repository doesn't exist yet, clone it
|
|
341
761
|
try:
|
|
342
762
|
os.makedirs(plugins_dir, exist_ok=True)
|
|
343
|
-
except (OSError, PermissionError)
|
|
344
|
-
logger.
|
|
763
|
+
except (OSError, PermissionError):
|
|
764
|
+
logger.exception(f"Cannot create plugin directory {plugins_dir}")
|
|
345
765
|
logger.error(f"Skipping repository {repo_name} due to permission error")
|
|
346
766
|
return False
|
|
347
767
|
|
|
@@ -351,9 +771,10 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
351
771
|
if is_default_branch:
|
|
352
772
|
try:
|
|
353
773
|
# Try to clone with the specified branch
|
|
354
|
-
|
|
774
|
+
_run(
|
|
355
775
|
["git", "clone", "--branch", ref_value, repo_url],
|
|
356
776
|
cwd=plugins_dir,
|
|
777
|
+
timeout=120,
|
|
357
778
|
)
|
|
358
779
|
if ref_type == "branch":
|
|
359
780
|
logger.info(
|
|
@@ -371,9 +792,10 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
371
792
|
logger.warning(
|
|
372
793
|
f"Could not clone with branch {ref_value}, trying {other_default}"
|
|
373
794
|
)
|
|
374
|
-
|
|
795
|
+
_run(
|
|
375
796
|
["git", "clone", "--branch", other_default, repo_url],
|
|
376
797
|
cwd=plugins_dir,
|
|
798
|
+
timeout=120,
|
|
377
799
|
)
|
|
378
800
|
logger.info(
|
|
379
801
|
f"Cloned repository {repo_name} from {repo_url} at branch {other_default}"
|
|
@@ -384,8 +806,10 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
384
806
|
logger.warning(
|
|
385
807
|
f"Could not clone with branch {other_default}, cloning default branch"
|
|
386
808
|
)
|
|
387
|
-
|
|
388
|
-
["git", "clone", repo_url],
|
|
809
|
+
_run(
|
|
810
|
+
["git", "clone", repo_url],
|
|
811
|
+
cwd=plugins_dir,
|
|
812
|
+
timeout=120,
|
|
389
813
|
)
|
|
390
814
|
logger.info(
|
|
391
815
|
f"Cloned repository {repo_name} from {repo_url} (default branch)"
|
|
@@ -395,9 +819,10 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
395
819
|
# It's a tag, try to clone with the tag
|
|
396
820
|
try:
|
|
397
821
|
# Try to clone with the specified tag
|
|
398
|
-
|
|
822
|
+
_run(
|
|
399
823
|
["git", "clone", "--branch", ref_value, repo_url],
|
|
400
824
|
cwd=plugins_dir,
|
|
825
|
+
timeout=120,
|
|
401
826
|
)
|
|
402
827
|
if ref_type == "branch":
|
|
403
828
|
logger.info(
|
|
@@ -413,13 +838,17 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
413
838
|
logger.warning(
|
|
414
839
|
f"Could not clone with tag {ref_value}, cloning default branch"
|
|
415
840
|
)
|
|
416
|
-
|
|
841
|
+
_run(
|
|
842
|
+
["git", "clone", repo_url],
|
|
843
|
+
cwd=plugins_dir,
|
|
844
|
+
timeout=120,
|
|
845
|
+
)
|
|
417
846
|
|
|
418
847
|
# Then try to fetch and checkout the tag
|
|
419
848
|
try:
|
|
420
849
|
# Try to fetch the tag
|
|
421
850
|
try:
|
|
422
|
-
|
|
851
|
+
_run(
|
|
423
852
|
[
|
|
424
853
|
"git",
|
|
425
854
|
"-C",
|
|
@@ -431,7 +860,7 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
431
860
|
)
|
|
432
861
|
except subprocess.CalledProcessError:
|
|
433
862
|
# If that fails, try to fetch the tag without the refs/tags/ prefix
|
|
434
|
-
|
|
863
|
+
_run(
|
|
435
864
|
[
|
|
436
865
|
"git",
|
|
437
866
|
"-C",
|
|
@@ -443,8 +872,9 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
443
872
|
)
|
|
444
873
|
|
|
445
874
|
# Now checkout the tag
|
|
446
|
-
|
|
447
|
-
["git", "-C", repo_path, "checkout", ref_value]
|
|
875
|
+
_run(
|
|
876
|
+
["git", "-C", repo_path, "checkout", ref_value],
|
|
877
|
+
timeout=120,
|
|
448
878
|
)
|
|
449
879
|
if ref_type == "branch":
|
|
450
880
|
logger.info(
|
|
@@ -461,11 +891,13 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
461
891
|
logger.warning(
|
|
462
892
|
f"Could not checkout {ref_value} as a tag, trying as a branch"
|
|
463
893
|
)
|
|
464
|
-
|
|
465
|
-
["git", "-C", repo_path, "fetch", "origin", ref_value]
|
|
894
|
+
_run(
|
|
895
|
+
["git", "-C", repo_path, "fetch", "origin", ref_value],
|
|
896
|
+
timeout=120,
|
|
466
897
|
)
|
|
467
|
-
|
|
468
|
-
["git", "-C", repo_path, "checkout", ref_value]
|
|
898
|
+
_run(
|
|
899
|
+
["git", "-C", repo_path, "checkout", ref_value],
|
|
900
|
+
timeout=120,
|
|
469
901
|
)
|
|
470
902
|
logger.info(
|
|
471
903
|
f"Cloned repository {repo_name} and checked out branch {ref_value}"
|
|
@@ -479,77 +911,30 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
479
911
|
f"Cloned repository {repo_name} from {repo_url} (default branch)"
|
|
480
912
|
)
|
|
481
913
|
return True
|
|
482
|
-
except subprocess.CalledProcessError
|
|
483
|
-
logger.
|
|
914
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
915
|
+
logger.exception(f"Error cloning repository {repo_name}")
|
|
484
916
|
logger.error(
|
|
485
917
|
f"Please manually git clone the repository {repo_url} into {repo_path}"
|
|
486
918
|
)
|
|
487
919
|
return False
|
|
488
|
-
# Install requirements if requirements.txt exists
|
|
489
|
-
requirements_path = os.path.join(repo_path, "requirements.txt")
|
|
490
|
-
if os.path.isfile(requirements_path):
|
|
491
|
-
try:
|
|
492
|
-
# Check if we're running in a pipx environment
|
|
493
|
-
in_pipx = "PIPX_HOME" in os.environ or "PIPX_LOCAL_VENVS" in os.environ
|
|
494
|
-
|
|
495
|
-
# Read requirements from file
|
|
496
|
-
with open(requirements_path, "r") as f:
|
|
497
|
-
requirements = [
|
|
498
|
-
line.strip()
|
|
499
|
-
for line in f
|
|
500
|
-
if line.strip() and not line.startswith("#")
|
|
501
|
-
]
|
|
502
|
-
|
|
503
|
-
if requirements:
|
|
504
|
-
if in_pipx:
|
|
505
|
-
# Use pipx inject for each requirement
|
|
506
|
-
logger.info(
|
|
507
|
-
f"Installing requirements for plugin {repo_name} with pipx inject"
|
|
508
|
-
)
|
|
509
|
-
for req in requirements:
|
|
510
|
-
logger.info(f"Installing {req}")
|
|
511
|
-
subprocess.check_call(["pipx", "inject", "mmrelay", req])
|
|
512
|
-
else:
|
|
513
|
-
# Use pip to install the requirements.txt
|
|
514
|
-
logger.info(
|
|
515
|
-
f"Installing requirements for plugin {repo_name} with pip"
|
|
516
|
-
)
|
|
517
|
-
subprocess.check_call(
|
|
518
|
-
[
|
|
519
|
-
sys.executable,
|
|
520
|
-
"-m",
|
|
521
|
-
"pip",
|
|
522
|
-
"install",
|
|
523
|
-
"-r",
|
|
524
|
-
requirements_path,
|
|
525
|
-
]
|
|
526
|
-
)
|
|
527
|
-
logger.info(
|
|
528
|
-
f"Successfully installed requirements for plugin {repo_name}"
|
|
529
|
-
)
|
|
530
|
-
except subprocess.CalledProcessError as e:
|
|
531
|
-
logger.error(f"Error installing requirements for plugin {repo_name}: {e}")
|
|
532
|
-
logger.error(
|
|
533
|
-
f"Please manually install the requirements from {requirements_path}"
|
|
534
|
-
)
|
|
535
|
-
# Don't exit, just continue with a warning
|
|
536
|
-
logger.warning(
|
|
537
|
-
f"Plugin {repo_name} may not work correctly without its dependencies"
|
|
538
|
-
)
|
|
539
920
|
|
|
540
921
|
|
|
541
922
|
def load_plugins_from_directory(directory, recursive=False):
|
|
542
923
|
"""
|
|
543
|
-
|
|
924
|
+
Load and instantiate Plugin classes from Python files in a directory.
|
|
544
925
|
|
|
545
|
-
|
|
926
|
+
Searches `directory` (optionally recursively) for .py files, imports each module in an isolated module name and, if the module defines a `Plugin` class, instantiates and collects it. If an import fails with ModuleNotFoundError, the function will (when auto-install is enabled in the global `config`) attempt to install the missing distribution with pip or pipx, refresh import paths, and retry importing the module. Files that do not define `Plugin` are skipped; unresolved import errors or other exceptions are logged and do not abort the whole scan.
|
|
546
927
|
|
|
547
928
|
Parameters:
|
|
548
|
-
directory (str): Path to the directory containing plugin files.
|
|
549
|
-
recursive (bool): If True,
|
|
929
|
+
directory (str): Path to the directory containing plugin Python files.
|
|
930
|
+
recursive (bool): If True, scan subdirectories recursively; otherwise only the top-level directory.
|
|
550
931
|
|
|
551
932
|
Returns:
|
|
552
|
-
list:
|
|
933
|
+
list: Instances of found plugin classes (may be empty).
|
|
934
|
+
|
|
935
|
+
Notes:
|
|
936
|
+
- The function mutates interpreter import state (may add entries to sys.modules) and can invoke external installers (pip/pipx) when auto-install is enabled.
|
|
937
|
+
- Only modules that define a top-level `Plugin` attribute are instantiated and returned.
|
|
553
938
|
"""
|
|
554
939
|
plugins = []
|
|
555
940
|
if os.path.isdir(directory):
|
|
@@ -564,6 +949,11 @@ def load_plugins_from_directory(directory, recursive=False):
|
|
|
564
949
|
spec = importlib.util.spec_from_file_location(
|
|
565
950
|
module_name, plugin_path
|
|
566
951
|
)
|
|
952
|
+
if not spec or not getattr(spec, "loader", None):
|
|
953
|
+
logger.warning(
|
|
954
|
+
f"Skipping plugin {plugin_path}: no import spec/loader."
|
|
955
|
+
)
|
|
956
|
+
continue
|
|
567
957
|
plugin_module = importlib.util.module_from_spec(spec)
|
|
568
958
|
|
|
569
959
|
# Create a compatibility layer for plugins
|
|
@@ -579,18 +969,11 @@ def load_plugins_from_directory(directory, recursive=False):
|
|
|
579
969
|
|
|
580
970
|
sys.modules["plugins"] = mmrelay.plugins
|
|
581
971
|
|
|
582
|
-
|
|
583
|
-
# Add the plugin's directory to sys.path temporarily
|
|
584
|
-
plugin_dir = os.path.dirname(plugin_path)
|
|
585
|
-
sys.path.insert(0, plugin_dir)
|
|
586
|
-
|
|
587
|
-
# Execute the module
|
|
588
|
-
spec.loader.exec_module(plugin_module)
|
|
589
|
-
|
|
590
|
-
# Remove the plugin directory from sys.path
|
|
591
|
-
if plugin_dir in sys.path:
|
|
592
|
-
sys.path.remove(plugin_dir)
|
|
972
|
+
plugin_dir = os.path.dirname(plugin_path)
|
|
593
973
|
|
|
974
|
+
try:
|
|
975
|
+
with _temp_sys_path(plugin_dir):
|
|
976
|
+
spec.loader.exec_module(plugin_module)
|
|
594
977
|
if hasattr(plugin_module, "Plugin"):
|
|
595
978
|
plugins.append(plugin_module.Plugin())
|
|
596
979
|
else:
|
|
@@ -598,13 +981,30 @@ def load_plugins_from_directory(directory, recursive=False):
|
|
|
598
981
|
f"{plugin_path} does not define a Plugin class."
|
|
599
982
|
)
|
|
600
983
|
except ModuleNotFoundError as e:
|
|
601
|
-
missing_module =
|
|
984
|
+
missing_module = getattr(e, "name", None)
|
|
985
|
+
if not missing_module:
|
|
986
|
+
m = re.search(
|
|
987
|
+
r"No module named ['\"]([^'\"]+)['\"]", str(e)
|
|
988
|
+
)
|
|
989
|
+
missing_module = m.group(1) if m else str(e)
|
|
990
|
+
# Prefer top-level distribution name for installation
|
|
991
|
+
raw = (missing_module or "").strip()
|
|
992
|
+
top = raw.split(".", 1)[0]
|
|
993
|
+
m = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", top)
|
|
994
|
+
if not m:
|
|
995
|
+
logger.warning(
|
|
996
|
+
f"Refusing to auto-install suspicious dependency name from {plugin_path!s}: {raw!r}"
|
|
997
|
+
)
|
|
998
|
+
raise
|
|
999
|
+
missing_pkg = m.group(0)
|
|
602
1000
|
logger.warning(
|
|
603
|
-
f"Missing dependency for plugin {plugin_path}: {
|
|
1001
|
+
f"Missing dependency for plugin {plugin_path}: {missing_pkg}"
|
|
604
1002
|
)
|
|
605
1003
|
|
|
606
1004
|
# Try to automatically install the missing dependency
|
|
607
1005
|
try:
|
|
1006
|
+
if not _check_auto_install_enabled(config):
|
|
1007
|
+
_raise_install_error(missing_pkg)
|
|
608
1008
|
# Check if we're running in a pipx environment
|
|
609
1009
|
in_pipx = (
|
|
610
1010
|
"PIPX_HOME" in os.environ
|
|
@@ -613,32 +1013,52 @@ def load_plugins_from_directory(directory, recursive=False):
|
|
|
613
1013
|
|
|
614
1014
|
if in_pipx:
|
|
615
1015
|
logger.info(
|
|
616
|
-
f"Attempting to install missing dependency with pipx inject: {
|
|
1016
|
+
f"Attempting to install missing dependency with pipx inject: {missing_pkg}"
|
|
617
1017
|
)
|
|
618
|
-
|
|
619
|
-
|
|
1018
|
+
pipx_path = shutil.which("pipx")
|
|
1019
|
+
if not pipx_path:
|
|
1020
|
+
raise FileNotFoundError(
|
|
1021
|
+
"pipx executable not found on PATH"
|
|
1022
|
+
)
|
|
1023
|
+
_run(
|
|
1024
|
+
[pipx_path, "inject", "mmrelay", missing_pkg],
|
|
1025
|
+
timeout=300,
|
|
620
1026
|
)
|
|
621
1027
|
else:
|
|
1028
|
+
in_venv = (
|
|
1029
|
+
sys.prefix
|
|
1030
|
+
!= getattr(sys, "base_prefix", sys.prefix)
|
|
1031
|
+
) or ("VIRTUAL_ENV" in os.environ)
|
|
622
1032
|
logger.info(
|
|
623
|
-
f"Attempting to install missing dependency with pip: {
|
|
624
|
-
)
|
|
625
|
-
subprocess.check_call(
|
|
626
|
-
[
|
|
627
|
-
sys.executable,
|
|
628
|
-
"-m",
|
|
629
|
-
"pip",
|
|
630
|
-
"install",
|
|
631
|
-
missing_module,
|
|
632
|
-
]
|
|
1033
|
+
f"Attempting to install missing dependency with pip: {missing_pkg}"
|
|
633
1034
|
)
|
|
1035
|
+
cmd = [
|
|
1036
|
+
sys.executable,
|
|
1037
|
+
"-m",
|
|
1038
|
+
"pip",
|
|
1039
|
+
"install",
|
|
1040
|
+
missing_pkg,
|
|
1041
|
+
"--disable-pip-version-check",
|
|
1042
|
+
"--no-input",
|
|
1043
|
+
]
|
|
1044
|
+
if not in_venv:
|
|
1045
|
+
cmd += ["--user"]
|
|
1046
|
+
_run(cmd, timeout=300)
|
|
634
1047
|
|
|
635
1048
|
logger.info(
|
|
636
|
-
f"Successfully installed {
|
|
1049
|
+
f"Successfully installed {missing_pkg}, retrying plugin load"
|
|
637
1050
|
)
|
|
1051
|
+
try:
|
|
1052
|
+
_refresh_dependency_paths()
|
|
1053
|
+
except (OSError, ImportError, AttributeError) as e:
|
|
1054
|
+
logger.debug(
|
|
1055
|
+
f"Path refresh after auto-install failed: {e}"
|
|
1056
|
+
)
|
|
638
1057
|
|
|
639
1058
|
# Try to load the module again
|
|
640
1059
|
try:
|
|
641
|
-
|
|
1060
|
+
with _temp_sys_path(plugin_dir):
|
|
1061
|
+
spec.loader.exec_module(plugin_module)
|
|
642
1062
|
|
|
643
1063
|
if hasattr(plugin_module, "Plugin"):
|
|
644
1064
|
plugins.append(plugin_module.Plugin())
|
|
@@ -647,31 +1067,26 @@ def load_plugins_from_directory(directory, recursive=False):
|
|
|
647
1067
|
f"{plugin_path} does not define a Plugin class."
|
|
648
1068
|
)
|
|
649
1069
|
except ModuleNotFoundError:
|
|
650
|
-
logger.
|
|
1070
|
+
logger.exception(
|
|
651
1071
|
f"Module {missing_module} still not available after installation. "
|
|
652
1072
|
f"The package name might be different from the import name."
|
|
653
1073
|
)
|
|
654
|
-
except Exception
|
|
655
|
-
logger.
|
|
656
|
-
|
|
1074
|
+
except Exception:
|
|
1075
|
+
logger.exception(
|
|
1076
|
+
"Error loading plugin %s after dependency installation",
|
|
1077
|
+
plugin_path,
|
|
657
1078
|
)
|
|
658
1079
|
|
|
659
1080
|
except subprocess.CalledProcessError:
|
|
660
|
-
logger.
|
|
661
|
-
f"Failed to automatically install {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
f"
|
|
1081
|
+
logger.exception(
|
|
1082
|
+
f"Failed to automatically install {missing_pkg}. "
|
|
1083
|
+
f"Please install manually:\n"
|
|
1084
|
+
f" pipx inject mmrelay {missing_pkg} # if using pipx\n"
|
|
1085
|
+
f" pip install {missing_pkg} # if using pip\n"
|
|
1086
|
+
f" pip install --user {missing_pkg} # if not in a venv"
|
|
666
1087
|
)
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
)
|
|
670
|
-
logger.error(
|
|
671
|
-
f"Plugin directory: {os.path.dirname(plugin_path)}"
|
|
672
|
-
)
|
|
673
|
-
except Exception as e:
|
|
674
|
-
logger.error(f"Error loading plugin {plugin_path}: {e}")
|
|
1088
|
+
except Exception:
|
|
1089
|
+
logger.exception(f"Error loading plugin {plugin_path}")
|
|
675
1090
|
if not recursive:
|
|
676
1091
|
break
|
|
677
1092
|
else:
|
|
@@ -775,8 +1190,8 @@ def load_plugins(passed_config=None):
|
|
|
775
1190
|
)
|
|
776
1191
|
plugin_found = True
|
|
777
1192
|
break
|
|
778
|
-
except Exception
|
|
779
|
-
logger.
|
|
1193
|
+
except Exception:
|
|
1194
|
+
logger.exception(f"Failed to load custom plugin {plugin_name}")
|
|
780
1195
|
continue
|
|
781
1196
|
|
|
782
1197
|
if not plugin_found:
|
|
@@ -788,10 +1203,13 @@ def load_plugins(passed_config=None):
|
|
|
788
1203
|
community_plugins_config = config.get("community-plugins", {})
|
|
789
1204
|
community_plugin_dirs = get_community_plugin_dirs()
|
|
790
1205
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1206
|
+
if not community_plugin_dirs:
|
|
1207
|
+
logger.warning(
|
|
1208
|
+
"No writable community plugin directories available; clone/update operations will be skipped."
|
|
1209
|
+
)
|
|
1210
|
+
community_plugins_dir = None
|
|
1211
|
+
else:
|
|
1212
|
+
community_plugins_dir = community_plugin_dirs[0]
|
|
795
1213
|
|
|
796
1214
|
# Create community plugins directory if needed
|
|
797
1215
|
active_community_plugins = [
|
|
@@ -844,13 +1262,23 @@ def load_plugins(passed_config=None):
|
|
|
844
1262
|
ref = {"type": "branch", "value": "main"}
|
|
845
1263
|
|
|
846
1264
|
if repo_url:
|
|
1265
|
+
if community_plugins_dir is None:
|
|
1266
|
+
logger.warning(
|
|
1267
|
+
"Skipping community plugin %s: no accessible plugin directory",
|
|
1268
|
+
plugin_name,
|
|
1269
|
+
)
|
|
1270
|
+
continue
|
|
1271
|
+
|
|
847
1272
|
# Clone to the user directory by default
|
|
1273
|
+
repo_name = os.path.splitext(os.path.basename(repo_url.rstrip("/")))[0]
|
|
848
1274
|
success = clone_or_update_repo(repo_url, ref, community_plugins_dir)
|
|
849
1275
|
if not success:
|
|
850
1276
|
logger.warning(
|
|
851
1277
|
f"Failed to clone/update plugin {plugin_name}, skipping"
|
|
852
1278
|
)
|
|
853
1279
|
continue
|
|
1280
|
+
repo_path = os.path.join(community_plugins_dir, repo_name)
|
|
1281
|
+
_install_requirements_for_repo(repo_path, repo_name)
|
|
854
1282
|
else:
|
|
855
1283
|
logger.error("Repository URL not specified for a community plugin")
|
|
856
1284
|
logger.error("Please specify the repository URL in config.yaml")
|
|
@@ -876,9 +1304,9 @@ def load_plugins(passed_config=None):
|
|
|
876
1304
|
)
|
|
877
1305
|
plugin_found = True
|
|
878
1306
|
break
|
|
879
|
-
except Exception
|
|
880
|
-
logger.
|
|
881
|
-
|
|
1307
|
+
except Exception:
|
|
1308
|
+
logger.exception(
|
|
1309
|
+
"Failed to load community plugin %s", repo_name
|
|
882
1310
|
)
|
|
883
1311
|
continue
|
|
884
1312
|
|
|
@@ -888,7 +1316,8 @@ def load_plugins(passed_config=None):
|
|
|
888
1316
|
)
|
|
889
1317
|
else:
|
|
890
1318
|
logger.error(
|
|
891
|
-
|
|
1319
|
+
"Repository URL not specified for community plugin: %s",
|
|
1320
|
+
plugin_name,
|
|
892
1321
|
)
|
|
893
1322
|
|
|
894
1323
|
# Filter and sort active plugins by priority
|
|
@@ -919,8 +1348,8 @@ def load_plugins(passed_config=None):
|
|
|
919
1348
|
active_plugins.append(plugin)
|
|
920
1349
|
try:
|
|
921
1350
|
plugin.start()
|
|
922
|
-
except Exception
|
|
923
|
-
logger.
|
|
1351
|
+
except Exception:
|
|
1352
|
+
logger.exception(f"Error starting plugin {plugin_name}")
|
|
924
1353
|
|
|
925
1354
|
sorted_active_plugins = sorted(active_plugins, key=lambda plugin: plugin.priority)
|
|
926
1355
|
|