mmrelay 1.2.0__py3-none-any.whl → 1.2.2__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/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 specified plugin type, including user and local plugin directories if accessible.
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): Either "custom" or "community", specifying the type of plugins.
339
+ plugin_type (str): Plugin category, e.g. "custom" or "community".
36
340
 
37
341
  Returns:
38
- list: List of plugin directories to search, with the user directory first if available, followed by the local directory for backward compatibility.
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 and ensure its dependencies are installed.
452
+ Clone or update a community plugin Git repository.
83
453
 
84
- Attempts to clone the repository at the specified branch or tag, or update it if it already exists. Handles switching between branches and tags, falls back to default branches if needed, and installs Python dependencies from `requirements.txt` using either pip or pipx. Logs errors and warnings for any issues encountered.
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
- repo_url (str): The URL of the Git repository to clone or update.
88
- ref (dict): Reference specification with keys:
89
- - type: "tag" or "branch"
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 or updated and dependencies were handled; False if any critical error occurred.
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
- subprocess.check_call(["git", "-C", repo_path, "fetch", "origin"])
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 = subprocess.check_output(
520
+ current_branch = _run(
127
521
  ["git", "-C", repo_path, "rev-parse", "--abbrev-ref", "HEAD"],
128
- universal_newlines=True,
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
- subprocess.check_call(
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
- subprocess.check_call(
148
- ["git", "-C", repo_path, "checkout", ref_value]
542
+ _run(
543
+ ["git", "-C", repo_path, "checkout", ref_value],
544
+ timeout=120,
149
545
  )
150
- subprocess.check_call(
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
- subprocess.check_call(
166
- ["git", "-C", repo_path, "checkout", other_default]
562
+ _run(
563
+ ["git", "-C", repo_path, "checkout", other_default],
564
+ timeout=120,
167
565
  )
168
- subprocess.check_call(
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 = subprocess.check_output(
608
+ current_commit = _run(
187
609
  ["git", "-C", repo_path, "rev-parse", "HEAD"],
188
- universal_newlines=True,
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 = subprocess.check_output(
616
+ tag_commit = _run(
195
617
  ["git", "-C", repo_path, "rev-parse", ref_value],
196
- universal_newlines=True,
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
- subprocess.check_call(
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
- if ref_type == "branch":
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
- subprocess.check_call(
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
- subprocess.check_call(
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
- subprocess.check_call(
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
- subprocess.check_call(
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
- subprocess.check_call(
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
- subprocess.check_call(
286
- ["git", "-C", repo_path, "checkout", ref_value]
701
+ _run(
702
+ ["git", "-C", repo_path, "checkout", ref_value],
703
+ timeout=120,
287
704
  )
288
- subprocess.check_call(
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
- subprocess.check_call(
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
- subprocess.check_call(
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) as e:
344
- logger.error(f"Cannot create plugin directory {plugins_dir}: {e}")
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
- subprocess.check_call(
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
- subprocess.check_call(
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
- subprocess.check_call(
388
- ["git", "clone", repo_url], cwd=plugins_dir
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
- subprocess.check_call(
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
- subprocess.check_call(["git", "clone", repo_url], cwd=plugins_dir)
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
- subprocess.check_call(
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
- subprocess.check_call(
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
- subprocess.check_call(
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
- subprocess.check_call(
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
- subprocess.check_call(
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 as e:
483
- logger.error(f"Error cloning repository {repo_name}: {e}")
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
- Dynamically loads and instantiates plugin classes from Python files in a specified directory.
924
+ Load and instantiate Plugin classes from Python files in a directory.
544
925
 
545
- Scans the given directory (and subdirectories if `recursive` is True) for `.py` files, importing each as a module and instantiating its `Plugin` class if present. Automatically attempts to install missing dependencies when a `ModuleNotFoundError` occurs, supporting both pip and pipx environments. Provides compatibility for plugins importing from either `plugins` or `mmrelay.plugins`. Skips files without a `Plugin` class or with unresolved import errors.
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, searches subdirectories recursively.
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: Instantiated plugin objects found in the directory.
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
- try:
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 = str(e).split()[-1].strip("'")
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}: {missing_module}"
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: {missing_module}"
1016
+ f"Attempting to install missing dependency with pipx inject: {missing_pkg}"
617
1017
  )
618
- subprocess.check_call(
619
- ["pipx", "inject", "mmrelay", missing_module]
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: {missing_module}"
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 {missing_module}, retrying plugin load"
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
- spec.loader.exec_module(plugin_module)
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.error(
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 as retry_error:
655
- logger.error(
656
- f"Error loading plugin {plugin_path} after dependency installation: {retry_error}"
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.error(
661
- f"Failed to automatically install {missing_module}"
662
- )
663
- logger.error("Please install it manually:")
664
- logger.error(
665
- f"pipx inject mmrelay {missing_module} # if using pipx"
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
- logger.error(
668
- f"pip install {missing_module} # if using pip"
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 as e:
779
- logger.error(f"Failed to load custom plugin {plugin_name}: {e}")
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
- # Get the first directory for cloning (prefer user directory)
792
- community_plugins_dir = community_plugin_dirs[
793
- 0
794
- ] # Use the user directory for new clones
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 as e:
880
- logger.error(
881
- f"Failed to load community plugin {repo_name}: {e}"
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
- f"Repository URL not specified for community plugin: {plugin_name}"
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 as e:
923
- logger.error(f"Error starting plugin {plugin_name}: {e}")
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