Skip to content

Commit 5dfb906

Browse files
committed
feat(update): add background plugin update notifications
Discover community plugins at startup and check PyPI for newer versions in a daemon thread, printing notifications after CLI execution. Uses a separate cache file and reuses the same gate logic (CI, quiet, frequency) as the core update check.
1 parent c15689a commit 5dfb906

4 files changed

Lines changed: 719 additions & 2 deletions

File tree

packages/deepctl-cmd-update/src/deepctl_cmd_update/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
from .command import UpdateCommand
44
from .installation import InstallationDetector, InstallationInfo, InstallMethod
55
from .models import UpdateResult
6+
from .plugin_update_check import (
7+
check_plugins_and_notify,
8+
print_pending_plugin_notifications,
9+
)
610
from .startup_check import check_and_notify, print_pending_notification
711
from .version_check import VersionChecker, VersionInfo, format_version_message
812

@@ -15,6 +19,8 @@
1519
"VersionChecker",
1620
"VersionInfo",
1721
"check_and_notify",
22+
"check_plugins_and_notify",
1823
"format_version_message",
1924
"print_pending_notification",
25+
"print_pending_plugin_notifications",
2026
]
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""Background plugin update check for deepctl.
2+
3+
Discovers community plugins (excluding first-party packages) and checks
4+
PyPI for newer versions. Runs in a daemon thread alongside the core
5+
update check and prints notifications to stderr after CLI execution.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import importlib.metadata
11+
import sys
12+
import threading
13+
import time
14+
from pathlib import Path
15+
from typing import Any
16+
17+
from .startup_check import _get_check_interval_seconds, _is_ci, _is_oneshot
18+
19+
# ---------------------------------------------------------------------------
20+
# Constants
21+
# ---------------------------------------------------------------------------
22+
23+
_CACHE_DIR = Path.home() / ".cache" / "deepctl"
24+
_PLUGIN_CACHE_FILE = _CACHE_DIR / "last_plugin_version_check"
25+
26+
# First-party packages are version-locked to deepctl — never check these.
27+
_EXCLUDED_PREFIXES = ("deepctl-cmd-", "deepctl-core", "deepctl-shared-")
28+
_EXCLUDED_EXACT = frozenset({"deepctl", "deepctl-plugin-example"})
29+
30+
# Hard cap on the number of plugins to check (avoid slow startups).
31+
_MAX_PLUGINS_TO_CHECK = 10
32+
33+
# ---------------------------------------------------------------------------
34+
# Helpers
35+
# ---------------------------------------------------------------------------
36+
37+
38+
def _is_excluded(name: str) -> bool:
39+
"""Return True if *name* is a first-party package that should be skipped."""
40+
if name in _EXCLUDED_EXACT:
41+
return True
42+
return any(name.startswith(prefix) for prefix in _EXCLUDED_PREFIXES)
43+
44+
45+
def _read_plugin_cache_timestamp() -> float:
46+
"""Read the last plugin-check timestamp from the cache file.
47+
48+
Returns:
49+
Unix timestamp of the last check, or ``0.0`` if not available.
50+
"""
51+
try:
52+
return float(_PLUGIN_CACHE_FILE.read_text().strip())
53+
except (FileNotFoundError, ValueError, OSError):
54+
return 0.0
55+
56+
57+
def _write_plugin_cache_timestamp() -> None:
58+
"""Persist the current time to the plugin cache file."""
59+
try:
60+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
61+
_PLUGIN_CACHE_FILE.write_text(str(time.time()))
62+
except OSError:
63+
pass
64+
65+
66+
# ---------------------------------------------------------------------------
67+
# Discovery
68+
# ---------------------------------------------------------------------------
69+
70+
71+
def _discover_community_plugins() -> dict[str, str]:
72+
"""Return ``{package_name: installed_version}`` for community plugins.
73+
74+
Sources:
75+
76+
1. ``importlib.metadata.distributions()`` — packages on ``sys.path``
77+
that expose ``deepctl.plugins`` entry points.
78+
2. ``plugins.json`` — isolated-venv plugins tracked by the plugin
79+
manager but not necessarily on ``sys.path``.
80+
"""
81+
plugins: dict[str, str] = {}
82+
83+
# --- Source 1: entry-point-based discovery ---
84+
try:
85+
for dist in importlib.metadata.distributions():
86+
# Check if this distribution provides deepctl.plugins entry points
87+
eps = dist.metadata.get_all("Provides-Extra") or []
88+
dist_name = dist.metadata["Name"]
89+
if dist_name is None:
90+
continue
91+
92+
# Normalise for comparison
93+
normalised = dist_name.lower().replace("_", "-")
94+
95+
# Check for deepctl.plugins entry points via the distribution
96+
try:
97+
dist_eps = dist.entry_points
98+
has_plugin_ep = any(
99+
ep.group == "deepctl.plugins" for ep in dist_eps
100+
)
101+
except Exception:
102+
has_plugin_ep = False
103+
104+
if has_plugin_ep and not _is_excluded(normalised):
105+
try:
106+
plugins[normalised] = dist.metadata["Version"] or "0.0.0"
107+
except Exception:
108+
pass
109+
except Exception:
110+
pass
111+
112+
# --- Source 2: plugins.json for isolated-venv plugins ---
113+
try:
114+
from deepctl_core.plugin_env import get_plugin_state
115+
116+
state = get_plugin_state()
117+
for pkg_name, info in state.get("plugins", {}).items():
118+
normalised = pkg_name.lower().replace("_", "-")
119+
if normalised not in plugins and not _is_excluded(normalised):
120+
version = (
121+
info.get("version", "0.0.0") if isinstance(info, dict) else "0.0.0"
122+
)
123+
plugins[normalised] = version
124+
except Exception:
125+
pass
126+
127+
return plugins
128+
129+
130+
# ---------------------------------------------------------------------------
131+
# PyPI check
132+
# ---------------------------------------------------------------------------
133+
134+
135+
def _check_pypi_versions(
136+
plugins: dict[str, str],
137+
) -> list[dict[str, str]]:
138+
"""Query PyPI for each plugin and return those with available updates.
139+
140+
Returns:
141+
List of ``{"name": ..., "current": ..., "latest": ...}`` dicts
142+
for plugins where a newer version exists on PyPI.
143+
"""
144+
if not plugins:
145+
return []
146+
147+
updates: list[dict[str, str]] = []
148+
149+
try:
150+
import httpx
151+
from packaging import version as pkg_version
152+
153+
with httpx.Client(timeout=5.0) as client:
154+
for name, current_ver in list(plugins.items())[: _MAX_PLUGINS_TO_CHECK]:
155+
try:
156+
resp = client.get(f"https://pypi.org/pypi/{name}/json")
157+
resp.raise_for_status()
158+
data = resp.json()
159+
latest_ver: str = data["info"]["version"]
160+
161+
if pkg_version.parse(latest_ver) > pkg_version.parse(current_ver):
162+
updates.append(
163+
{
164+
"name": name,
165+
"current": current_ver,
166+
"latest": latest_ver,
167+
}
168+
)
169+
except Exception:
170+
continue
171+
except Exception:
172+
pass
173+
174+
return updates
175+
176+
177+
# ---------------------------------------------------------------------------
178+
# Background thread
179+
# ---------------------------------------------------------------------------
180+
181+
182+
def _background_plugin_check(result: dict[str, Any]) -> None:
183+
"""Run plugin discovery + PyPI check (called inside a daemon thread)."""
184+
try:
185+
plugins = _discover_community_plugins()
186+
if not plugins:
187+
return
188+
189+
updates = _check_pypi_versions(plugins)
190+
if updates:
191+
result["updates"] = updates
192+
193+
_write_plugin_cache_timestamp()
194+
except Exception:
195+
pass
196+
197+
198+
# ---------------------------------------------------------------------------
199+
# Module-level state
200+
# ---------------------------------------------------------------------------
201+
202+
_thread: threading.Thread | None = None
203+
_result: dict[str, Any] = {}
204+
205+
206+
# ---------------------------------------------------------------------------
207+
# Public API
208+
# ---------------------------------------------------------------------------
209+
210+
211+
def check_plugins_and_notify(quiet: bool = False) -> None:
212+
"""Start a background plugin version check.
213+
214+
Call this **before** CLI execution. It spawns a daemon thread that
215+
runs concurrently. Call :func:`print_pending_plugin_notifications`
216+
after CLI execution to display the results.
217+
218+
Args:
219+
quiet: If True, suppress the check entirely.
220+
"""
221+
global _thread, _result
222+
_result = {}
223+
224+
if quiet or _is_ci() or _is_oneshot():
225+
return
226+
227+
interval = _get_check_interval_seconds(quiet)
228+
if interval is None:
229+
return
230+
231+
last_check = _read_plugin_cache_timestamp()
232+
if time.time() - last_check < interval:
233+
return
234+
235+
_thread = threading.Thread(
236+
target=_background_plugin_check,
237+
args=(_result,),
238+
daemon=True,
239+
)
240+
_thread.start()
241+
242+
243+
def print_pending_plugin_notifications() -> None:
244+
"""Print plugin update notifications if any are pending.
245+
246+
Call this **after** CLI execution completes. It joins the background
247+
thread (with a short timeout) and, if updates were found, prints
248+
ANSI-colored lines to stderr.
249+
"""
250+
global _thread
251+
252+
if _thread is None:
253+
return
254+
255+
_thread.join(timeout=3.0)
256+
_thread = None
257+
258+
updates = _result.get("updates")
259+
if not updates:
260+
return
261+
262+
if len(updates) == 1:
263+
u = updates[0]
264+
sys.stderr.write(
265+
f"\033[33mPlugin update available: {u['name']} {u['current']}{u['latest']}"
266+
f" — run 'deepctl plugin update {u['name']}' to upgrade\033[0m\n"
267+
)
268+
else:
269+
sys.stderr.write("\033[33mPlugin updates available:\033[0m\n")
270+
for u in updates:
271+
sys.stderr.write(
272+
f"\033[33m {u['name']} {u['current']}{u['latest']}\033[0m\n"
273+
)
274+
sys.stderr.write(
275+
"\033[33mRun 'deepctl plugin update <name>' to upgrade\033[0m\n"
276+
)

0 commit comments

Comments
 (0)