-
-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathcompatibility.py
More file actions
332 lines (279 loc) · 14.7 KB
/
Copy pathcompatibility.py
File metadata and controls
332 lines (279 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"""One place that answers "can this plugin run on this core?".
Two callers ask that question and they must not drift apart:
- `PluginLoader._warn_if_incompatible` — at load time, **advisory**. A plugin
already on disk keeps loading regardless, because the guarded-import pattern
means most incompatibilities degrade rather than break.
- `PluginStoreManager.install_plugin` — at install/update time, **blocking**.
This is the point where refusing costs the user nothing (they keep the
version they already had) and allowing can cost them a plugin that fails to
load with only a log line to explain it.
## The trustworthiness problem
The core's own `__version__` has not always been right. `v3.1.0` was tagged
2026-05-31 while `src/__init__.py` still said `"1.0.0"`; the bump landed
2026-07-12. Devices installed from that release report `1.0.0` — below the
floor that essentially every published plugin declares.
So a core reporting a version below `TRUSTWORTHY_FLOOR` is treated as
**unknown, not old**: it neither warns nor blocks. Blocking on it would be far
worse than the problem being solved — nearly every manifest in the ecosystem
floors at `2.0.0`, so a strict gate would stop those users installing *any*
plugin. They are unprotected until they update the core, which is also what
fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
# Below this, the core's self-reported version is not evidence of anything.
# See the module docstring.
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
# ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/
_VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py"
_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
def current_core_version() -> str:
"""The core version as it is on disk right now, not as it was at import.
``from src import __version__`` binds whatever the process loaded at start.
The web UI runs as its own long-lived service (``ledmatrix-web.service``),
and updating the core replaces files on disk without restarting it -- the
update route says so explicitly and asks the user to restart. Its prompt
names the *display* service, so a user who follows it leaves the web
process holding the old number.
The plugin store's gate lives in that web process. Stale by one release is
exactly the case that matters: every plugin flooring on the release you
just installed gets refused, with a message blaming a core version that is
already correct on disk. 3.3.0 is the first release where that hits a whole
plugin family at once -- all eight sports scoreboards floor there.
Reading the file costs one stat and a small read per call, and only on the
install/update path. Any failure falls back to the imported value, so this
can only ever be as wrong as before, never worse.
"""
try:
text = _VERSION_FILE.read_text(encoding="utf-8")
match = _VERSION_RE.search(text)
if match:
return match.group(1)
except (OSError, UnicodeDecodeError):
pass
try:
from src import __version__ as imported
return imported
except Exception: # noqa: BLE001 - never let this raise
return "0.0.0"
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
if not isinstance(value, str):
return None
text = value.strip().lstrip('v')
# Drop the prerelease/build suffix before scraping digits. Without this the
# scrape pulls them into the numbers: "3.2.0+build42" parsed as (3, 2, 42)
# and "3.2.0-rc1" as (3, 2, 1) -- a release candidate ranking *above* its
# own release, and a build of 3.2.0 failing an exact "3.2.0" match.
#
# Prereleases compare equal to their release here rather than below it.
# Full prerelease ordering is more than any caller needs, and equal is far
# closer to right than the old behaviour.
for sep in ('+', '-'):
head, found, _tail = text.partition(sep)
if found:
text = head
parts = text.split('.')
try:
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
except ValueError:
return None
while len(nums) < 3:
nums.append(0)
return tuple(nums) # type: ignore[return-value]
# `parse_semver` is deliberately lenient — it strips non-digits and yields
# (0, 0, 0) for a string with no numbers at all, which is fine for a floor
# (a floor of 0.0.0 never blocks anything) but wrong for a range, where the
# same leniency would turn an unreadable spec into a *refusal*. Range specs
# are therefore validated against this first, so garbage reads as "no
# evidence" rather than "incompatible".
_VERSION_TOKEN = re.compile(r"^v?\d+(\.\d+){0,2}(-[\w.-]+)?(\+[\w.-]+)?$")
def _parse_strict(value: str) -> Optional[Tuple[int, int, int]]:
"""`parse_semver`, but ``None`` unless the string really looks like one."""
if not isinstance(value, str) or not _VERSION_TOKEN.match(value.strip()):
return None
return parse_semver(value)
def _satisfies_range(core: Tuple[int, int, int], spec: str) -> Optional[bool]:
"""Does ``core`` satisfy one `compatible_versions` entry?
Returns ``None`` when the spec cannot be parsed — the caller treats that as
"no evidence" rather than as a refusal, so an unrecognised spelling never
costs a user a working install.
Supports the forms `schema/manifest_schema.json` permits: `>=`, `<=`, `>`,
`<`, `~`, `^`, a bare exact version, and an inclusive `A - B` range.
Prerelease/build suffixes are tolerated and ignored, matching `parse_semver`.
"""
spec = spec.strip()
if not spec:
return None
if " - " in spec: # inclusive range, e.g. "2.0.0 - 3.1.0"
low_raw, _, high_raw = spec.partition(" - ")
low, high = _parse_strict(low_raw), _parse_strict(high_raw)
if low is None or high is None:
return None
return low <= core <= high
for op in (">=", "<=", ">", "<", "~", "^"):
if spec.startswith(op):
target = _parse_strict(spec[len(op):])
if target is None:
return None
if op == ">=":
return core >= target
if op == "<=":
return core <= target
if op == ">":
return core > target
if op == "<":
return core < target
if op == "~":
# Patch-level changes only: >=X.Y.Z, <X.(Y+1).0
return target <= core < (target[0], target[1] + 1, 0)
# "^": minor and patch changes: >=X.Y.Z, <(X+1).0.0
return target <= core < (target[0] + 1, 0, 0)
exact = _parse_strict(spec)
return None if exact is None else core == exact
def satisfies_compatible_versions(
manifest: Dict[str, Any], core: Tuple[int, int, int]
) -> Optional[bool]:
"""Evaluate the manifest's `compatible_versions` array against ``core``.
The array is a set of *alternatives*: satisfying any one entry means the
plugin declares itself compatible. Returns ``None`` when the field is
absent or no entry could be parsed, so callers can distinguish "declared
incompatible" from "did not say".
This is the field `schema/manifest_schema.json` marks **required**, and it
is the only one that can express an upper bound — `ledmatrix_min_version`
is a floor and cannot say "not compatible with 4.x".
"""
specs = manifest.get('compatible_versions')
if not isinstance(specs, list) or not specs:
return None
verdicts = [_satisfies_range(core, s) for s in specs if isinstance(s, str)]
parsed = [v for v in verdicts if v is not None]
if not parsed:
return None
return any(parsed)
def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
"""The core version this plugin says it needs, or ``None`` if it doesn't say.
Checked in order of specificity. `ledmatrix_min` is the deprecated spelling
of `ledmatrix_min_version` (`store_manager._validate_manifest_fields` flags
it); both are read because a large share of published manifests still carry
the old one.
Container types are validated rather than assumed. A hand-edited or
third-party manifest can carry `requires` as a list or `versions` as a
mapping, and both used to raise out of here (`AttributeError` and
`KeyError` respectively). That now matters far more than it did: the
untrustworthy-core branch of :func:`check` calls this for *every* manifest,
so one malformed file would take down the install path rather than just
itself. A shape we do not recognise means "no declared floor".
"""
declared = manifest.get('min_ledmatrix_version')
if not declared:
requires = manifest.get('requires')
if isinstance(requires, dict):
declared = requires.get('min_ledmatrix_version')
if declared:
return declared
versions = manifest.get('versions')
if isinstance(versions, list) and versions and isinstance(versions[0], dict):
return (versions[0].get('ledmatrix_min_version')
or versions[0].get('ledmatrix_min'))
return None
def is_update_available(installed_version: str, latest_version: str) -> bool:
"""Return True when the registry's ``latest_version`` is strictly newer
than the installed version.
THE shared comparator for "should this plugin be updated?" — used by both
the web UI's update badge (`api_v3._is_plugin_update_available`) and the
store's `update_plugin` reinstall decision, so the two can never disagree.
Uses PEP 440-aware comparison (``packaging``), which also normalizes
equivalent spellings: ``v1.2.0`` == ``1.2.0`` and ``1.2`` == ``1.2.0``, so
cosmetic differences never trigger a reinstall — and a locally modified
plugin whose version is *ahead* of the registry is never "updated"
(downgraded). If either version string can't be parsed the mismatch is
surfaced (True) so the user can reconcile, rather than silently hiding a
potential update.
"""
if not installed_version or not latest_version:
return False
if not isinstance(installed_version, str) or not isinstance(latest_version, str):
# A malformed manifest/registry can carry a number (1.2) or worse;
# packaging would raise TypeError. Surface the mismatch instead.
return True
if installed_version == latest_version:
return False
try:
from packaging.version import parse as _parse_version, InvalidVersion
except ImportError:
# packaging is a core dependency, but if it's somehow unavailable we
# can't compare semantically — surface the mismatch we already know
# exists (the two strings differ).
return True
try:
return _parse_version(latest_version) > _parse_version(installed_version)
except InvalidVersion:
# Unparseable version string: we can't tell direction, so surface the
# mismatch rather than silently hiding a potential update.
return True
def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]:
"""Return ``(compatible, reason)``.
Two fields can say a plugin is incompatible and **the more restrictive
wins**:
- `compatible_versions` — the schema-required array of semver ranges, and
the only one that can express an upper bound.
- `ledmatrix_min_version` (or the deprecated `ledmatrix_min`) — the
per-release floor inside `versions[]`.
They agree across every published manifest today except `7-segment-clock`,
but they *can* disagree, and a plugin that says `["2.0.0 - 2.9.9"]` means
"not compatible with 3.x" no matter what its floor says.
``compatible`` is False **only** on evidence: the core reports a parseable,
trustworthy version and a field genuinely excludes it. Every uncertain case
resolves to compatible — nothing declared, an unparseable version on either
side, or a core below `TRUSTWORTHY_FLOOR`. Refusing on a guess breaks a
working install, which is the more expensive mistake here.
``reason`` is user-facing text, present only when incompatible.
"""
current = parse_semver(core_version)
name = manifest.get('name') or manifest.get('id') or 'This plugin'
if current is None or current < TRUSTWORTHY_FLOOR:
# The version is not evidence of what this core HAS. But a floor above
# the ecosystem baseline says the plugin needs modules that arrived
# *after* 2.0.0 — and a core reporting below that either is the v3.1.0
# release (which ships __version__ = "1.0.0" and has none of the 3.2.0
# modules) or is genuinely ancient. Either way it will not have them.
#
# This is the only protection available to that population: they cannot
# be told apart from a real 1.0.0 install, so the gate cannot reason
# about them, and the *plugin's* guarded-import fallback disappears at
# the B6 sunset. Refusing the install leaves them on the version they
# already run instead of handing them one that fails to load.
#
# Floors at or below 2.0.0 are still allowed, which is every manifest
# published today — so this does not lock anyone out of the store.
declared = declared_min_version(manifest)
needed = parse_semver(declared)
if needed is not None and needed > TRUSTWORTHY_FLOOR:
return False, (
f"{name} requires LEDMatrix {declared} or newer. This system "
f"reports {core_version}, which is too old to identify "
f"reliably — update LEDMatrix, then install it."
)
return True, None
# Ranges first: they are the canonical field and can rule out a core that
# clears the floor.
if satisfies_compatible_versions(manifest, current) is False:
specs = ", ".join(
s for s in manifest.get('compatible_versions', []) if isinstance(s, str))
return False, (
f"{name} supports LEDMatrix {specs}, but this system is running "
f"{core_version}. Install a build in that range, or a plugin "
f"version that supports {core_version}."
)
declared = declared_min_version(manifest)
needed = parse_semver(declared)
if needed is not None and needed > current:
return False, (
f"{name} requires LEDMatrix {declared} or newer, but this system is "
f"running {core_version}. Update LEDMatrix first, then install it."
)
return True, None