Files
LEDMatrix/src/plugin_system/store_registry.py
T
ChuckandClaude Opus 5.5 989eae9405 refactor(plugins): split PluginStoreManager into mixins (#659)
* refactor(plugins): split PluginStoreManager into mixins

src/plugin_system/store_manager.py (2,977 lines) keeps the class, its
shared state, locks, the uninstall registry, directory lookup and
uninstall; its methods are split by area into:
- store_registry.py (_RegistryMixin): registry, GitHub metadata, search,
  manifest validation
- store_install.py (_InstallMixin): install paths and dependencies
- store_update.py (_UpdateMixin): updates, rollback, local git state

Pure move: all 56 members are byte-identical (checked with ast) and the
assembled class has exactly the same attributes as before (checked at
runtime). PluginStoreManager is imported from store_manager.py as before.
Tests that patched shared modules (subprocess, requests, tempfile, shutil)
through store_manager now reach them through the module whose code they
exercise; a source-text contract test reads all store_*.py modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: annotate findings the split moved into new store modules

subprocess imports and a list-form git clone (no shell), and the config
template's placeholder token string -- existing code that Codacy reported
as new because it moved. Annotated with the repo's nosec/nosemgrep style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: annotate the default-branch git clone the split moved

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:00:45 -04:00

853 lines
40 KiB
Python

"""Plugin store: the plugin registry, GitHub metadata, search and manifest
validation.
Part of PluginStoreManager (store_manager.py), which mixes this class in;
methods reach shared state and helpers through ``self``.
"""
import json
import requests
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional, Any
from jsonschema import Draft7Validator, ValidationError
from src.plugin_system.repo_urls import (
github_api_headers, github_owner_repo, normalize_repo_url,
)
class _RegistryMixin:
"""PluginStoreManager methods: see the module docstring."""
def _load_github_token(self) -> Optional[str]:
"""
Load GitHub API token from config_secrets.json if available.
Returns:
GitHub token or None if not configured
"""
try:
config_path = Path(__file__).parent.parent.parent / "config" / "config_secrets.json"
if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
token = config.get('github', {}).get('api_token', '').strip()
# The config template's placeholder, not a credential.
if token and token != "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN": # nosec B105 # nosemgrep
return token
except Exception as e:
self.logger.debug(f"Could not load GitHub token: {e}")
return None
def _validate_github_token(self, token: str) -> tuple[bool, Optional[str]]:
"""
Validate a GitHub token by making a lightweight API call.
Args:
token: GitHub personal access token to validate
Returns:
Tuple of (is_valid, error_message)
- is_valid: True if token is valid, False otherwise
- error_message: None if valid, error description if invalid
"""
if not token:
return (False, "No token provided")
# Check cache first
cache_key = token[:10] # Use first 10 chars as cache key for privacy
if cache_key in self._token_validation_cache:
cached_valid, cached_time, cached_error = self._token_validation_cache[cache_key]
if time.time() - cached_time < self._token_validation_cache_timeout:
return (cached_valid, cached_error)
# Validate token by making a lightweight API call to /user endpoint
try:
api_url = "https://api.github.com/user"
response = requests.get(api_url, headers=github_api_headers(token), timeout=5)
if response.status_code == 200:
# Token is valid
result = (True, None)
self._token_validation_cache[cache_key] = (True, time.time(), None)
return result
elif response.status_code == 401:
# Token is invalid or expired
error_msg = "Token is invalid or expired"
result = (False, error_msg)
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
return result
elif response.status_code == 403:
# Rate limit or forbidden (but token might be valid)
# Check if it's a rate limit issue
if 'rate limit' in response.text.lower():
# Rate limit: return error but don't cache (rate limits are temporary)
error_msg = "Rate limit exceeded"
result = (False, error_msg)
return result
else:
# Token lacks permissions: cache the result (permissions don't change)
error_msg = "Token lacks required permissions"
result = (False, error_msg)
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
return result
else:
# Other error
error_msg = f"GitHub API error: {response.status_code}"
result = (False, error_msg)
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
return result
except requests.exceptions.Timeout:
error_msg = "GitHub API request timed out"
result = (False, error_msg)
# Don't cache timeout errors
return result
except requests.exceptions.RequestException as e:
error_msg = f"Network error: {str(e)}"
result = (False, error_msg)
# Don't cache network errors
return result
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
result = (False, error_msg)
# Don't cache unexpected errors
return result
@staticmethod
def _iso_to_date(iso_timestamp: str) -> str:
"""Convert an ISO timestamp to YYYY-MM-DD string."""
if not iso_timestamp:
return ""
try:
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
return dt.strftime('%Y-%m-%d')
except Exception:
return ""
@staticmethod
def _distinct_sequence(values: List[str]) -> List[str]:
"""Return list preserving order while removing duplicates and falsey entries."""
seen = set()
ordered = []
for value in values:
if not value:
continue
if value in seen:
continue
seen.add(value)
ordered.append(value)
return ordered
def _validate_manifest_version_fields(self, manifest: Dict[str, Any]) -> List[str]:
"""
Validate version-related fields in manifest for consistency.
Checks:
- compatible_versions is present and is an array
- Standardized field names are used (min_ledmatrix_version, max_ledmatrix_version)
- Deprecated fields are not used (ledmatrix_version)
- versions array entries use ledmatrix_min_version instead of ledmatrix_min
Args:
manifest: Manifest dictionary to validate
Returns:
List of validation error/warning messages (empty if valid)
"""
errors = []
# Check compatible_versions is an array
if 'compatible_versions' in manifest:
if not isinstance(manifest['compatible_versions'], list):
errors.append("compatible_versions must be an array")
elif len(manifest['compatible_versions']) == 0:
errors.append("compatible_versions array cannot be empty")
# Warn about deprecated ledmatrix_version field
if 'ledmatrix_version' in manifest:
errors.append("ledmatrix_version is deprecated, use compatible_versions instead")
# Check versions array entries use standardized field names
if 'versions' in manifest and isinstance(manifest['versions'], list):
for i, version_entry in enumerate(manifest['versions']):
if not isinstance(version_entry, dict):
continue
# Check for old ledmatrix_min field
if 'ledmatrix_min' in version_entry and 'ledmatrix_min_version' not in version_entry:
errors.append(f"versions[{i}] uses deprecated 'ledmatrix_min', should use 'ledmatrix_min_version'")
return errors
def _validate_manifest_schema(self, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
"""
Validate manifest against JSON schema if available.
Args:
manifest: Manifest dictionary to validate
plugin_id: Plugin ID for error messages
Returns:
List of validation error messages (empty if valid or schema unavailable)
"""
try:
# Load manifest schema
schema_path = Path(__file__).parent.parent.parent / "schema" / "manifest_schema.json"
if not schema_path.exists():
return [] # Schema not available, skip validation
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
# Validate schema itself
Draft7Validator.check_schema(schema)
# Validate manifest against schema
validator = Draft7Validator(schema)
errors = []
for error in validator.iter_errors(manifest):
error_path = '.'.join(str(p) for p in error.path)
errors.append(f"{error_path}: {error.message}")
return errors
except json.JSONDecodeError as e:
self.logger.warning(f"Could not parse manifest schema: {e}")
return []
except ValidationError as e:
self.logger.warning(f"Manifest schema is invalid: {e}")
return []
except Exception as e:
self.logger.debug(f"Error validating manifest schema for {plugin_id}: {e}")
return []
_EMPTY_REPO_INFO: Dict[str, Any] = {
'stars': 0,
'forks': 0,
'open_issues': 0,
'updated_at_iso': '',
'last_commit_iso': '',
'last_commit_date': '',
'language': '',
'license': '',
'default_branch': 'main',
}
def _get_github_repo_info(self, repo_url: str) -> Dict[str, Any]:
"""GitHub metadata for a repository (stars, default branch, last push).
Returns zeroed defaults (``_EMPTY_REPO_INFO``) for a non-GitHub URL or
when GitHub cannot be asked and nothing is cached.
"""
try:
owner_repo = github_owner_repo(repo_url)
if owner_repo is None:
return dict(self._EMPTY_REPO_INFO)
owner, repo = owner_repo
cache_key = f"{owner}/{repo}"
if cache_key in self.github_cache:
cached_time, cached_data = self.github_cache[cache_key]
if time.time() - cached_time < self.cache_timeout:
return cached_data
api_url = f"https://api.github.com/repos/{owner}/{repo}"
try:
response = requests.get(
api_url, headers=github_api_headers(self.github_token), timeout=10)
except requests.RequestException as req_err:
# Network error: prefer a stale cache hit over an empty
# default so the UI keeps working on a flaky Pi WiFi link.
# Bump the cached entry's timestamp into a short backoff
# window so subsequent requests serve the stale payload
# cheaply instead of re-hitting the network on every request.
if cache_key in self.github_cache:
_, stale = self.github_cache[cache_key]
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
self.logger.warning(
"GitHub repo info fetch failed for %s (%s); serving stale cache.",
cache_key, req_err,
)
return stale
raise
if response.status_code == 200:
data = response.json()
pushed_at = data.get('pushed_at', '') or data.get('updated_at', '')
repo_info = {
'stars': data.get('stargazers_count', 0),
'forks': data.get('forks_count', 0),
'open_issues': data.get('open_issues_count', 0),
'updated_at_iso': data.get('updated_at', ''),
'last_commit_iso': pushed_at,
'last_commit_date': self._iso_to_date(pushed_at),
'language': data.get('language', ''),
'license': data.get('license', {}).get('name', '') if data.get('license') else '',
'default_branch': data.get('default_branch', 'main')
}
self.github_cache[cache_key] = (time.time(), repo_info)
return repo_info
if response.status_code == 403:
# Rate limit or authentication issue. A stale star count is
# better than a reset to zero, and the backoff bump stops the
# store hammering the API while rate-limited.
if cache_key in self.github_cache:
_, stale = self.github_cache[cache_key]
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
self.logger.warning(
"GitHub API 403 for %s; serving stale cache.", cache_key,
)
return stale
if not self.github_token:
self.logger.warning(
"GitHub API rate limit likely exceeded (403). "
"Add a GitHub personal access token to config/config_secrets.json "
"under 'github.api_token' to increase rate limits from 60 to 5000/hour."
)
else:
self.logger.warning(
f"GitHub API request failed: 403 for {api_url}. "
f"Your token may have insufficient permissions or rate limit exceeded."
)
else:
self.logger.warning(f"GitHub API request failed: {response.status_code} for {api_url}")
if cache_key in self.github_cache:
_, stale = self.github_cache[cache_key]
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
return stale
return dict(self._EMPTY_REPO_INFO)
except requests.exceptions.RequestException as e:
# Offline, DNS or a timeout reaching GitHub: the listing still
# works without the extra repo info, so this is not an error.
self.logger.warning("GitHub repo info unavailable for %s: %s", repo_url, e)
return dict(self._EMPTY_REPO_INFO)
except Exception as e:
self.logger.error(f"Error fetching GitHub repo info for {repo_url}: {e}")
return dict(self._EMPTY_REPO_INFO)
def _http_get_with_retries(self, url: str, *, timeout: int = 10, stream: bool = False, headers: Dict[str, str] = None, max_retries: int = 3, backoff_sec: float = 0.75):
"""
HTTP GET with simple retry strategy and exponential backoff.
Returns a requests.Response or raises the last exception.
"""
last_exc = None
for attempt in range(1, max_retries + 1):
try:
resp = requests.get(url, timeout=timeout, stream=stream, headers=headers)
return resp
except requests.RequestException as e:
last_exc = e
self.logger.warning(f"HTTP GET failed (attempt {attempt}/{max_retries}) for {url}: {e}")
if attempt < max_retries:
time.sleep(backoff_sec * attempt)
# Exhausted retries
raise last_exc
def fetch_registry_from_url(self, repo_url: str) -> Optional[Dict]:
"""
Fetch a registry-style plugins.json from a custom GitHub repository URL.
This allows users to point to a registry-style monorepo (like the official
ledmatrix-plugins repo) and browse/install plugins from it.
Args:
repo_url: GitHub repository URL (e.g., https://github.com/user/ledmatrix-plugins)
Returns:
Registry dict with plugins list, or None if not found/invalid
"""
try:
repo_url = normalize_repo_url(repo_url)
# plugins.json or registry.json at the root of main, then master.
registry_urls = []
owner_repo = github_owner_repo(repo_url)
if owner_repo is not None:
owner, repo = owner_repo
for branch in ['main', 'master']:
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/plugins.json")
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/registry.json")
for url in registry_urls:
try:
response = self._http_get_with_retries(url, timeout=10)
if response.status_code == 200:
registry = response.json()
# Validate it looks like a registry
if isinstance(registry, dict) and 'plugins' in registry:
self.logger.info(f"Successfully fetched registry from {url}")
return registry
except Exception as e:
self.logger.debug(f"Failed to fetch from {url}: {e}")
continue
self.logger.warning(f"No valid registry found at {repo_url}")
return None
except Exception as e:
self.logger.error(f"Error fetching registry from URL: {e}", exc_info=True)
return None
def fetch_registry(self, force_refresh: bool = False, raise_on_failure: bool = False) -> Dict:
"""
Fetch the plugin registry from GitHub.
Args:
force_refresh: Force refresh even if cached
raise_on_failure: If True, re-raise network / JSON errors instead
of silently falling back to stale cache / empty dict. UI
callers prefer the stale-fallback default so the plugin
list keeps working on flaky WiFi; the state reconciler
needs the explicit failure signal so it can distinguish
"plugin genuinely not in registry" from "I couldn't reach
the registry at all" and not mark everything unrecoverable.
Returns:
Registry data with list of available plugins
Raises:
requests.RequestException / json.JSONDecodeError when
``raise_on_failure`` is True and the fetch fails.
"""
# Check if cache is still valid (within timeout)
current_time = time.time()
if (self.registry_cache and self.registry_cache_time and
not force_refresh and
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache
with self._registry_fetch_lock:
# Re-check inside the lock — a concurrent caller that was waiting
# may have already populated the cache while we blocked.
current_time = time.time()
if (self.registry_cache and self.registry_cache_time and
not force_refresh and
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache
try:
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
response.raise_for_status()
self.registry_cache = response.json()
self.registry_cache_time = current_time
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
return self.registry_cache
except requests.RequestException as e:
self.logger.error(f"Error fetching registry: {e}")
if raise_on_failure:
raise
# Prefer stale cache over an empty list so the plugin list UI
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
# registry_cache_time into a short backoff window so the next
# request serves the stale payload cheaply instead of
# re-hitting the network on every request (matches the
# pattern used by github_cache / commit_info_cache).
if self.registry_cache:
self.logger.warning("Falling back to stale registry cache")
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
except json.JSONDecodeError as e:
self.logger.error(f"Error parsing registry JSON: {e}")
if raise_on_failure:
raise
if self.registry_cache:
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
"""
Search for plugins in the registry with enhanced metadata.
GitHub supplies live metadata such as stars and last commit
timestamps; the registry supplies descriptive information (name,
description, repo URL, etc.).
Args:
query: Search query string (searches name, description, id, author)
category: Filter by category (e.g., 'sports', 'weather', 'time')
tags: Filter by tags (matches any tag in list)
fetch_commit_info: If True (default), fetch commit metadata from GitHub.
include_saved_repos: If True (default), also search the
registry-style repositories the user saved.
saved_repositories_manager: The SavedRepositoriesManager holding
those repositories; without it only the official registry is
searched.
Returns:
List of matching plugin metadata enriched with GitHub information
"""
if tags is None:
tags = []
# Fetch from official registry
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
# Also fetch from saved repositories if enabled
if include_saved_repos and saved_repositories_manager:
saved_repos = saved_repositories_manager.get_registry_repositories()
for repo_info in saved_repos:
repo_url = repo_info.get('url')
if repo_url:
try:
custom_registry = self.fetch_registry_from_url(repo_url)
if custom_registry:
custom_plugins = custom_registry.get('plugins', []) or []
# Mark these as from custom repository
for plugin in custom_plugins:
plugin['_source'] = 'custom_repository'
plugin['_repository_url'] = repo_url
plugin['_repository_name'] = repo_info.get('name', repo_url)
plugins.extend(custom_plugins)
except Exception as e:
self.logger.warning(f"Failed to fetch plugins from saved repository {repo_url}: {e}")
# First pass: apply cheap filters (category/tags/query) so we only
# fetch GitHub metadata for plugins that will actually be returned.
filtered: List[Dict] = []
for plugin in plugins:
if category and plugin.get('category') != category:
continue
if tags and not any(tag in plugin.get('tags', []) for tag in tags):
continue
if query:
query_lower = query.lower()
searchable_text = ' '.join([
plugin.get('name', ''),
plugin.get('description', ''),
plugin.get('id', ''),
plugin.get('author', ''),
]).lower()
if query_lower not in searchable_text:
continue
filtered.append(plugin)
def _enrich(plugin: Dict) -> Dict:
"""Enrich a single plugin with GitHub metadata.
Called concurrently from a ThreadPoolExecutor. Both HTTP helpers
(``_get_github_repo_info`` / ``_get_latest_commit_info``) are
thread-safe -- they use ``requests`` and write their own cache
keys on Python dicts, which is atomic under the GIL for
single-key assignments.
"""
enhanced_plugin = plugin.copy()
repo_url = plugin.get('repo', '')
if not repo_url:
return enhanced_plugin
github_info = self._get_github_repo_info(repo_url)
enhanced_plugin['stars'] = github_info.get('stars', plugin.get('stars', 0))
enhanced_plugin['default_branch'] = github_info.get('default_branch', plugin.get('branch', 'main'))
enhanced_plugin['last_updated_iso'] = github_info.get('last_commit_iso')
enhanced_plugin['last_updated'] = github_info.get('last_commit_date')
if fetch_commit_info:
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
commit_info = self._get_latest_commit_info(repo_url, branch)
if commit_info:
enhanced_plugin['last_commit'] = commit_info.get('short_sha')
enhanced_plugin['last_commit_sha'] = commit_info.get('sha')
enhanced_plugin['last_updated'] = commit_info.get('date') or enhanced_plugin.get('last_updated')
enhanced_plugin['last_updated_iso'] = commit_info.get('date_iso') or enhanced_plugin.get('last_updated_iso')
enhanced_plugin['last_commit_message'] = commit_info.get('message')
enhanced_plugin['last_commit_author'] = commit_info.get('author')
enhanced_plugin['branch'] = commit_info.get('branch', branch)
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
# Intentionally NO per-plugin manifest.json fetch here.
# The registry's plugins.json already carries ``description``
# (it is generated from each plugin's manifest by
# ``update_registry.py``), and ``last_updated`` is filled in
# from the commit info above. Fetching manifest.json per
# plugin costs one extra HTTPS round trip per result; on a Pi4
# with a flaky WiFi link the tail retries of that one call
# (_http_get_with_retries does 3 attempts with exponential
# backoff) dominate wall time even with the thread pool.
return enhanced_plugin
# Fan out the per-plugin GitHub enrichment. Serially, a Pi4 with ~15
# plugins and a cold cache makes 30+ HTTP requests in strict sequence
# (the "connecting to display" hang users reported). With a thread
# pool, latency is dominated by the slowest request rather than
# their sum. Workers capped at 10 to stay well under the
# unauthenticated GitHub rate limit burst and avoid overwhelming a
# Pi's WiFi link.
if not filtered:
return []
# Not worth the pool overhead for tiny workloads. Parenthesized to
# make Python's default ``and`` > ``or`` precedence explicit: a
# single plugin, OR a small batch where we don't need commit info.
if (len(filtered) == 1) or ((not fetch_commit_info) and (len(filtered) < 4)):
return [_enrich(p) for p in filtered]
max_workers = min(10, len(filtered))
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='plugin-search') as executor:
# executor.map preserves input order, which the UI relies on.
return list(executor.map(_enrich, filtered))
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
"""
Fetch manifest.json directly from a GitHub repository.
Args:
repo_url: GitHub repository URL
branch: Branch name (default: master)
manifest_path: Path to manifest within the repo (default: manifest.json).
For monorepo plugins this will be e.g. "plugins/football-scoreboard/manifest.json".
force_refresh: If True, bypass the cache.
Returns:
Manifest data or None if not found
"""
try:
owner_repo = github_owner_repo(repo_url)
if owner_repo is None:
return None
owner, repo = owner_repo
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
if not force_refresh and cache_key in self.manifest_cache:
cached_time, cached_data = self.manifest_cache[cache_key]
if time.time() - cached_time < self.manifest_cache_timeout:
return cached_data
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
response = self._http_get_with_retries(raw_url, timeout=10)
if response.status_code == 200:
result = response.json()
self.manifest_cache[cache_key] = (time.time(), result)
return result
if response.status_code == 404 and branch != "main":
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
response = self._http_get_with_retries(raw_url, timeout=10)
if response.status_code == 200:
result = response.json()
self.manifest_cache[cache_key] = (time.time(), result)
return result
# Cache the miss too, so a plugin without a manifest at this path
# is not re-fetched on every browse.
self.manifest_cache[cache_key] = (time.time(), None)
except Exception as e:
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
return None
def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
"""Return metadata about the latest commit on the given branch."""
try:
owner_repo = github_owner_repo(repo_url)
if owner_repo is None:
return None
owner, repo = owner_repo
cache_key = f"{owner}/{repo}:{branch}"
if not force_refresh and cache_key in self.commit_info_cache:
cached_time, cached_data = self.commit_info_cache[cache_key]
if time.time() - cached_time < self.commit_cache_timeout:
return cached_data
branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
headers = github_api_headers(self.github_token)
last_error = None
for branch_name in branches_to_try:
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch_name}"
try:
response = requests.get(api_url, headers=headers, timeout=10)
except requests.RequestException as req_err:
# Network failure: fall back to a stale cache hit if
# available so the plugin store UI keeps populating
# commit info on a flaky WiFi link. Bump the cached
# timestamp into the backoff window so we don't
# re-retry on every request.
if cache_key in self.commit_info_cache:
_, stale = self.commit_info_cache[cache_key]
if stale is not None:
self._record_cache_backoff(
self.commit_info_cache, cache_key,
self.commit_cache_timeout, stale,
)
self.logger.warning(
"GitHub commit fetch failed for %s (%s); serving stale cache.",
cache_key, req_err,
)
return stale
last_error = str(req_err)
continue
if response.status_code == 200:
commit_data = response.json()
commit_sha_full = commit_data.get('sha', '')
commit_sha_short = commit_sha_full[:7] if commit_sha_full else ''
commit_meta = commit_data.get('commit', {})
commit_author = commit_meta.get('author', {})
commit_date_iso = commit_author.get('date', '')
result = {
'branch': branch_name,
'sha': commit_sha_full,
'short_sha': commit_sha_short,
'date_iso': commit_date_iso,
'date': self._iso_to_date(commit_date_iso),
'author': commit_author.get('name', ''),
'message': commit_meta.get('message', ''),
}
self.commit_info_cache[cache_key] = (time.time(), result)
return result
if response.status_code == 403 and not self.github_token:
self.logger.debug("GitHub commit API rate limited (403). Consider adding a token.")
last_error = response.text
else:
last_error = response.text
if last_error:
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
# All branches returned a non-200 response (e.g. 404 on every
# candidate, or a transient 5xx). If we already had a good
# cached value, prefer serving that — overwriting it with
# None here would wipe out commit info the UI just showed
# on the previous request. Bump the timestamp into the
# backoff window so subsequent lookups hit the cache.
if cache_key in self.commit_info_cache:
_, prior = self.commit_info_cache[cache_key]
if prior is not None:
self._record_cache_backoff(
self.commit_info_cache, cache_key,
self.commit_cache_timeout, prior,
)
return prior
# No prior good value — cache the negative result so we don't
# hammer a plugin that genuinely has no reachable commits.
self.commit_info_cache[cache_key] = (time.time(), None)
except Exception as e:
self.logger.debug(f"Error fetching latest commit metadata for {repo_url}: {e}")
return None
def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:
"""
Get detailed information about a plugin from the registry.
GitHub provides authoritative metadata such as stars and the latest
commit. The registry supplies descriptive information (name, id, repo URL).
Args:
plugin_id: Plugin identifier
fetch_latest_from_github: If True (default), augment with GitHub commit metadata.
force_refresh: If True, bypass caches for commit/manifest data.
Returns:
Plugin metadata or None if not found
"""
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
plugin_info = self._match_registry_entry(plugins, plugin_id)
if not plugin_info:
return None
if fetch_latest_from_github:
repo_url = plugin_info.get('repo')
if repo_url:
plugin_info = plugin_info.copy()
github_info = self._get_github_repo_info(repo_url)
branch = plugin_info.get('branch') or github_info.get('default_branch', 'main')
plugin_info['default_branch'] = github_info.get('default_branch', branch)
plugin_info['stars'] = github_info.get('stars', plugin_info.get('stars', 0))
plugin_info['last_updated'] = github_info.get('last_commit_date', plugin_info.get('last_updated'))
plugin_info['last_updated_iso'] = github_info.get('last_commit_iso', plugin_info.get('last_updated_iso'))
commit_info = self._get_latest_commit_info(repo_url, branch, force_refresh=force_refresh)
if commit_info:
plugin_info['last_commit'] = commit_info.get('short_sha')
plugin_info['last_commit_sha'] = commit_info.get('sha')
plugin_info['last_commit_message'] = commit_info.get('message')
plugin_info['last_commit_author'] = commit_info.get('author')
plugin_info['last_updated'] = commit_info.get('date') or plugin_info.get('last_updated')
plugin_info['last_updated_iso'] = commit_info.get('date_iso') or plugin_info.get('last_updated_iso')
plugin_info['branch'] = commit_info.get('branch', branch)
plugin_info['last_commit_branch'] = commit_info.get('branch')
plugin_subpath = plugin_info.get('plugin_path', '')
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel, force_refresh=force_refresh)
if github_manifest:
if 'last_updated' in github_manifest and not plugin_info.get('last_updated'):
plugin_info['last_updated'] = github_manifest['last_updated']
if 'description' in github_manifest:
plugin_info['description'] = github_manifest['description']
return plugin_info
@staticmethod
def _match_registry_entry(plugins: List[Dict], plugin_id: str) -> Optional[Dict]:
"""Find a registry entry by its id, or by the directory it installs to.
Four shipped plugins have a registry ``id`` that differs from the ``id``
in their own manifest: ``weather`` installs to ``plugins/ledmatrix-weather``,
and likewise stocks, music and leaderboard. Installation already prefers
the manifest id for the directory name, so on disk, in ``config.json``
and in a backup manifest those plugins are called ``ledmatrix-weather``.
Only the registry calls them ``weather``, and nothing resolved that in
reverse: restoring a backup asked the store for ``ledmatrix-weather``
and got "Plugin not found in registry", silently dropping four enabled
plugins from a restored device.
Matching ``plugin_path`` fixes it without renaming any published id,
which would orphan ``plugin_state.json`` entries keyed on the old ones.
Exact id always wins, so an entry whose *path* happens to collide with
another entry's id cannot shadow it.
"""
if not plugin_id:
return None
exact = next((p for p in plugins if p.get('id') == plugin_id), None)
if exact is not None:
return exact
for entry in plugins:
path = (entry.get('plugin_path') or '').rstrip('/')
if path and path.rsplit('/', 1)[-1] == plugin_id:
return entry
return None
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
"""
Get plugin information from the registry cache only (no GitHub API calls).
Use this for lightweight lookups where only registry fields are needed
(e.g., verified status, latest_version).
Args:
plugin_id: Plugin identifier
Returns:
Plugin metadata from registry or None if not found
"""
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
return self._match_registry_entry(plugins, plugin_id)