mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
feat(store): read ledmatrix_min_version, aliases and commit from the registry (#686)
The store reads three optional registry fields: ledmatrix_min_version (an incompatible install/update is refused before any download, with a "Needs LEDMatrix X+" card badge), aliases (update/uninstall/reinstall by registry id find a plugin installed under its manifest id, with registry proof only), and commit (shown and linked on the store card). An older plugins.json behaves as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,11 +13,62 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
from src.plugin_system.plugin_dirs import PLUGIN_DIR_PREFIX
|
||||
from src.plugin_system.repo_urls import (
|
||||
github_api_headers, github_owner_repo, normalize_repo_url,
|
||||
)
|
||||
|
||||
|
||||
# Registry entry fields the plugin monorepo's update_registry.py added after
|
||||
# 3.7.0. All optional: an older plugins.json has none of them, and every
|
||||
# reader here treats a missing or malformed one as "not stated".
|
||||
#
|
||||
# - ``ledmatrix_min_version``: the floor the plugin's manifest declares, so an
|
||||
# incompatible install or update is refused before the download
|
||||
# (`registry_incompatibility`). The post-download gate stays as the fallback.
|
||||
# - ``aliases``: other ids the plugin goes by (the manifest id when it differs
|
||||
# from the registry id, e.g. ``ledmatrix-weather`` for ``weather``). With
|
||||
# ``plugin_path``'s name, the only proof the store accepts that a folder
|
||||
# under another name is this plugin (`alternate_ids`).
|
||||
# - ``commit``: the monorepo commit that introduced ``latest_version``.
|
||||
# Informational only -- installs still come from the branch head.
|
||||
|
||||
|
||||
def declared_aliases(entry: Dict[str, Any]) -> Optional[List[str]]:
|
||||
"""The entry's ``aliases``, or None when it carries no such list."""
|
||||
aliases = entry.get('aliases')
|
||||
if not isinstance(aliases, list):
|
||||
return None
|
||||
own = entry.get('id')
|
||||
return [a for a in aliases if isinstance(a, str) and a and a != own]
|
||||
|
||||
|
||||
def alternate_ids(entry: Dict[str, Any]) -> List[str]:
|
||||
"""Ids other than the registry id that the registry *proves* an installed
|
||||
copy may carry: the entry's ``aliases``, then its ``plugin_path``
|
||||
directory name (all an older registry has).
|
||||
|
||||
Never ``ledmatrix-<id>`` on its own say-so. Store operations delete and
|
||||
replace what these ids resolve to, and an unrelated plugin can live in a
|
||||
folder of that name (owner decision on #686). A guess is only a hint:
|
||||
see `prefix_hint`.
|
||||
"""
|
||||
own = entry.get('id')
|
||||
ids: List[str] = list(declared_aliases(entry) or [])
|
||||
path = entry.get('plugin_path')
|
||||
if isinstance(path, str) and path.strip('/'):
|
||||
ids.append(path.rstrip('/').rsplit('/', 1)[-1])
|
||||
return [g for i, g in enumerate(ids) if g and g != own and g not in ids[:i]]
|
||||
|
||||
|
||||
def prefix_hint(plugin_id: Any) -> Optional[str]:
|
||||
"""``ledmatrix-<id>``: the legacy folder name worth *mentioning* when
|
||||
``plugin_id`` is not found -- never one to act on without registry proof."""
|
||||
if isinstance(plugin_id, str) and plugin_id and not plugin_id.startswith(PLUGIN_DIR_PREFIX):
|
||||
return PLUGIN_DIR_PREFIX + plugin_id
|
||||
return None
|
||||
|
||||
|
||||
class _RegistryMixin:
|
||||
"""PluginStoreManager methods: see the module docstring."""
|
||||
|
||||
@@ -821,19 +872,117 @@ class _RegistryMixin:
|
||||
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.
|
||||
another entry's id cannot shadow it. An entry's ``aliases`` (registries
|
||||
from after 3.7.0) come next, then ``plugin_path``, which is what an
|
||||
older registry has to go on.
|
||||
"""
|
||||
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:
|
||||
if plugin_id in (declared_aliases(entry) or ()):
|
||||
return entry
|
||||
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 registry_incompatibility(self, plugin_id: str,
|
||||
entry: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Why the registry says this core cannot run the plugin's latest
|
||||
release, or None when it says nothing against it.
|
||||
|
||||
Reads the entry's ``ledmatrix_min_version`` and asks
|
||||
``compatibility.check`` -- the same function, and so the same wording
|
||||
and the same leniency (an untrustworthy or unparseable core version
|
||||
allows), as the gate that runs on the downloaded manifest. That gate
|
||||
stays: it also sees ``compatible_versions``, and an older registry
|
||||
without the field says nothing here.
|
||||
|
||||
``entry`` defaults to the registry entry for ``plugin_id``. Any
|
||||
failure to read the registry answers None: the pre-check exists to
|
||||
refuse early on evidence, never to block on a guess.
|
||||
"""
|
||||
if entry is None:
|
||||
try:
|
||||
entry = self.get_registry_info(plugin_id)
|
||||
except Exception as e: # noqa: BLE001 - never block an install on this
|
||||
self.logger.debug("Registry lookup for %s failed: %s", plugin_id, e)
|
||||
return None
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
floor = entry.get('ledmatrix_min_version')
|
||||
if not isinstance(floor, str) or not floor.strip():
|
||||
return None
|
||||
from src.plugin_system import compatibility
|
||||
compatible, reason = compatibility.check(
|
||||
{'id': entry.get('id') or plugin_id, 'name': entry.get('name'),
|
||||
'min_ledmatrix_version': floor.strip()},
|
||||
compatibility.current_core_version())
|
||||
return None if compatible else reason
|
||||
|
||||
def _refuse_if_registry_incompatible(self, plugin_id: str, entry: Optional[Dict[str, Any]],
|
||||
action: str, record_as: Optional[str] = None) -> bool:
|
||||
"""Log and record a registry-based refusal; True when refused.
|
||||
|
||||
``record_as`` is the id the caller will ask `pop_refusal` about (the
|
||||
id it was handed, which may be an alias of ``plugin_id``).
|
||||
"""
|
||||
reason = self.registry_incompatibility(plugin_id, entry)
|
||||
if reason is None:
|
||||
return False
|
||||
self.logger.error("Refusing to %s %s before downloading it: %s",
|
||||
action, plugin_id, reason)
|
||||
self._note_refusal(record_as or plugin_id, reason)
|
||||
return True
|
||||
|
||||
def _note_refusal(self, plugin_id: str, reason: str) -> None:
|
||||
"""Remember why an install or update of ``plugin_id`` was refused, so
|
||||
the web route can say so instead of "check logs for details"."""
|
||||
refusals = self.__dict__.setdefault('_refusals', {})
|
||||
refusals[plugin_id] = reason
|
||||
|
||||
def pop_refusal(self, *plugin_ids: str) -> Optional[str]:
|
||||
"""The compatibility refusal recorded for any of ``plugin_ids`` since
|
||||
the last call, clearing them all; None when there was none."""
|
||||
refusals = self.__dict__.get('_refusals') or {}
|
||||
found = None
|
||||
for plugin_id in plugin_ids:
|
||||
reason = refusals.pop(plugin_id, None)
|
||||
if found is None and reason:
|
||||
found = reason
|
||||
return found
|
||||
|
||||
def _installed_id_candidates(self, plugin_id: str) -> List[str]:
|
||||
"""``plugin_id`` and the other ids the registry proves its installed
|
||||
copy may carry.
|
||||
|
||||
From the registry already in memory -- no fetch, because uninstall
|
||||
and the update lookup must work offline. With an entry: its id and
|
||||
`alternate_ids` (``aliases``, ``plugin_path`` name). Without one (no
|
||||
registry loaded yet, or a plugin that isn't in it): the id alone.
|
||||
A folder whose manifest declares one of these ids is found by the
|
||||
resolver's manifest pass whatever it is called.
|
||||
"""
|
||||
ids: List[str] = [plugin_id]
|
||||
cache = getattr(self, 'registry_cache', None)
|
||||
plugins = cache.get('plugins') if isinstance(cache, dict) else None
|
||||
entry = None
|
||||
if isinstance(plugins, list) and isinstance(plugin_id, str):
|
||||
entry = self._match_registry_entry(
|
||||
[p for p in plugins if isinstance(p, dict)], plugin_id)
|
||||
if entry is not None:
|
||||
ids.append(entry.get('id'))
|
||||
ids.extend(alternate_ids(entry))
|
||||
unique: List[str] = []
|
||||
for candidate in ids:
|
||||
if isinstance(candidate, str) and candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get plugin information from the registry cache only (no GitHub API calls).
|
||||
|
||||
Reference in New Issue
Block a user