Files
LEDMatrix/src/font_usage.py
T
ChuckandClaude Opus 5.5 f3894916a9 feat(web): show which plugins use each font; warn before deleting one (#619)
* feat(web): show which plugins use each font, warn before deleting one

The Fonts tab lists font files from the web process's own scan, and the
plugins that register fonts run in the display process, so the tab had no
way to say whether a font was in use before deleting it.

The display service now publishes {catalog key: [plugin ids]} to the
shared cache (font_usage_snapshot, src/font_usage.py), built from the
loaded plugins' FontManager.register_manager_font() registrations. A
daemon thread checks every 10 s and writes only when the usage changed
(plus a daily refresh so cache cleanup cannot expire it); it never raises.
Families, aliases (press_start, four_by_six, ...) and paths are resolved
through FontManager's catalog to the file stem the Fonts tab keys rows by;
fonts outside assets/fonts are left out. Unloading a plugin drops its
registrations (new FontManager.forget_manager_fonts).

GET /api/v3/fonts/catalog merges used_by into each row per request (the
5-minute scan cache is copied, never edited): a list of plugin ids, or
null when the display service has not reported. The tab shows a Used by
column ("unknown" / "-" / ids, rendered as text) and deleting an in-use
font names the plugins in the confirmation, from a fresh read. The server
still refuses only system fonts. Catalog fetches bypass the browser's
5-second API cache, which otherwise served the pre-delete list.

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

* fix: call forget_manager_fonts through a hasattr check pylint can follow

getattr(..., None) then callable() is fine at runtime, but pylint's E1102
("not callable") can't see through it, and Codacy fails the check on it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:50:42 -04:00

273 lines
11 KiB
Python

"""Which plugins use which font, published for the web interface.
The display service and the web interface are separate processes. Plugins
run, and register their fonts with ``FontManager.register_manager_font()``,
in the display service only; the web interface's Fonts tab lists the files in
``assets/fonts/`` from its own scan and has no FontManager to ask. So the
display service publishes a small snapshot to the shared cache directory --
the same channel, and the same file permissions, as ``display_current_state``
and ``plugin_error_snapshot``: files are 0660 and carry the cache directory's
group, so root writes and the web user reads.
FONT_USAGE_KEY written by the display service only
The snapshot::
{"generated_at": "2026-09-23T10:00:00",
"fonts": {"PressStart2P-Regular": ["calendar", "hello-world"],
"4x6-font": ["calendar"]}}
``fonts`` is keyed the way ``GET /api/v3/fonts/catalog`` keys its rows: the
file name in ``assets/fonts/`` without its extension, as it is on disk. A
plugin names a font by FontManager family ("press_start", "5x7", "6x13b"), by
alias, or by path; :func:`catalog_key_for` resolves each through FontManager's
own catalog to the file it loads and keys it by that file's stem. Fonts that
live outside ``assets/fonts/`` (a plugin's own ``plugin_id::family`` fonts)
and families FontManager cannot resolve are left out: the Fonts tab has no
row for them.
Only registrations are counted. ``get_font()`` does not know which plugin is
calling it, and many plugins open font files directly with PIL, so a font no
plugin registered may still be in use; the web interface says so.
"""
import logging
import os
import threading
import time
from datetime import datetime
from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple
logger = logging.getLogger(__name__)
FONT_USAGE_KEY = "font_usage_snapshot"
#: How often the display service checks for a change. A check compares two
#: integers and a set of plugin ids; the snapshot is rebuilt and written only
#: when one of them moved, so plugins that register fonts every frame (the
#: countdown plugin registers per countdown at render time) cost nothing.
TICK_INTERVAL = 10.0
#: Rewrite an unchanged snapshot this often anyway. The cache's disk cleanup
#: deletes entries older than its default retention (30 days), and a display
#: service that runs longer than that without a font change would otherwise
#: turn the Fonts tab back to "unknown".
REFRESH_INTERVAL = 24 * 3600.0
FONT_EXTENSIONS = ('.ttf', '.otf', '.bdf')
# Bounds on what the web interface accepts from the snapshot file.
_MAX_FONTS = 1000
_MAX_PLUGINS_PER_FONT = 200
_MAX_ID_CHARS = 200
def _fonts_dir() -> str:
from src.common.font_layout import resolve_asset_path
return resolve_asset_path("assets/fonts")
def _same_dir(a: str, b: str) -> bool:
try:
return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
except (OSError, ValueError):
return False
def catalog_key_for(family: Any, font_catalog: Dict[str, str],
fonts_dir: Optional[str] = None) -> Optional[str]:
"""The Fonts-tab catalog key for a family a plugin registered, or None.
Resolution, in order:
1. ``family`` as a FontManager family -- a scanned file ("5x7",
"pressstart2p-regular"), an alias ("press_start", "four_by_six",
"five_by_seven", "tom_thumb"), or a plugin's ``plugin_id::family``.
FontManager keys scanned files in lower case, so the lookup falls back
to ``family.lower()``.
2. ``family`` as a file name or path ("5x7.bdf",
"assets/fonts/4x6-font.ttf", an absolute path).
The key is the resolved file's name without its extension, and only for a
file directly in ``assets/fonts/`` -- anything else has no catalog row.
"""
if not isinstance(family, str):
return None
name = family.strip()
if not name:
return None
fonts_dir = fonts_dir or _fonts_dir()
path = font_catalog.get(name) or font_catalog.get(name.lower())
if not path:
if not name.lower().endswith(FONT_EXTENSIONS):
return None
path = name
directory, filename = os.path.split(str(path))
stem, ext = os.path.splitext(filename)
if not stem or ext.lower() not in FONT_EXTENSIONS:
return None
if directory:
if not os.path.isabs(directory):
from src.common.font_layout import resolve_asset_path
directory = resolve_asset_path(directory)
if not _same_dir(directory, fonts_dir):
return None
return stem
def build_font_usage(font_manager: Any,
plugin_ids: Optional[FrozenSet[str]] = None,
fonts_dir: Optional[str] = None) -> Dict[str, List[str]]:
"""``{catalog key: sorted plugin ids}`` from FontManager's registrations.
``plugin_ids`` limits it to loaded plugins: a plugin whose constructor
registered fonts and which then failed validation is not using them.
"""
fonts_dir = fonts_dir or _fonts_dir()
# list() copies in one step under the GIL; register_manager_font() runs
# on the render thread while this runs on the publisher's.
registrations = list(getattr(font_manager, 'manager_fonts', {}).items())
font_catalog = dict(getattr(font_manager, 'font_catalog', {}))
usage: Dict[str, set] = {}
for manager_id, elements in registrations:
if not isinstance(manager_id, str):
continue
if plugin_ids is not None and manager_id not in plugin_ids:
continue
for spec in list(elements.values()) if isinstance(elements, dict) else ():
family = spec.get('family') if isinstance(spec, dict) else None
key = catalog_key_for(family, font_catalog, fonts_dir)
if key is not None:
usage.setdefault(key, set()).add(manager_id)
return {key: sorted(ids) for key, ids in sorted(usage.items())}
class FontUsagePublisher:
"""Publishes the display service's font usage to the shared cache.
tick() is the whole job; start() calls it from a daemon thread every
TICK_INTERVAL seconds. Nothing here raises: a failure is logged at debug
and retried on the next tick.
"""
def __init__(self, cache_manager: Any, font_manager: Any, plugin_manager: Any = None,
clock: Callable[[], float] = time.monotonic) -> None:
self.cache_manager = cache_manager
self.font_manager = font_manager
self.plugin_manager = plugin_manager
self._clock = clock
self._signature: Optional[Tuple[Any, ...]] = None
# None forces a first write, replacing whatever a previous run of the
# service left in the cache with this run's usage.
self._published: Optional[Dict[str, List[str]]] = None
self._published_at: Optional[float] = None
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def _loaded_plugin_ids(self) -> Optional[FrozenSet[str]]:
plugins = getattr(self.plugin_manager, 'plugins', None)
if not isinstance(plugins, dict):
return None
return frozenset(list(plugins))
def tick(self) -> bool:
"""Publish if the usage changed. True if a snapshot was written."""
with self._lock:
try:
plugin_ids = self._loaded_plugin_ids()
signature = (
getattr(self.font_manager, 'manager_fonts_version', None),
getattr(self.font_manager, 'cache_generation', None),
plugin_ids,
)
now = self._clock()
refresh_due = (self._published_at is not None
and now - self._published_at >= REFRESH_INTERVAL)
if signature == self._signature and not refresh_due:
return False
usage = build_font_usage(self.font_manager, plugin_ids)
if usage == self._published and not refresh_due:
self._signature = signature
return False
self.cache_manager.set(FONT_USAGE_KEY, {
'generated_at': datetime.now().isoformat(timespec='seconds'),
'fonts': usage,
})
self._signature = signature
self._published = usage
self._published_at = now
return True
except Exception as err: # never let reporting break the display
logger.debug("Could not publish font usage: %s", err, exc_info=True)
return False
def start(self, interval: float = TICK_INTERVAL) -> None:
"""Tick now, then from a daemon thread until stop(). No-op while running."""
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
def run() -> None:
self.tick()
while not self._stop.wait(interval):
self.tick()
self._thread = threading.Thread(target=run, name="font-usage-publisher", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
def start_font_usage_publisher(cache_manager: Any, font_manager: Any,
plugin_manager: Any) -> Optional[FontUsagePublisher]:
"""Start publishing font usage for the web interface. Never raises.
Call from the display service, once its plugins are loaded.
"""
try:
if cache_manager is None or font_manager is None or plugin_manager is None:
return None
publisher = FontUsagePublisher(cache_manager, font_manager, plugin_manager)
publisher.start()
return publisher
except Exception as err:
logger.warning("Font usage reporting to the web interface is unavailable: %s", err)
return None
# --- Reading side (web interface) -------------------------------------------
def read_font_usage(cache_manager: Any) -> Optional[Dict[str, Any]]:
"""The display service's latest snapshot, or None if it has not published.
memory_ttl=0: the display service writes this key, so only the file is
current. The contents are checked and bounded -- they arrive from another
process through a shared directory.
"""
try:
snapshot = cache_manager.get(FONT_USAGE_KEY, max_age=None, memory_ttl=0)
except Exception as err:
logger.debug("Could not read font usage: %s", err, exc_info=True)
return None
if not isinstance(snapshot, dict) or not isinstance(snapshot.get('fonts'), dict):
return None
fonts: Dict[str, List[str]] = {}
for key, ids in list(snapshot['fonts'].items())[:_MAX_FONTS]:
if not isinstance(key, str) or not isinstance(ids, list):
continue
clean = sorted({i[:_MAX_ID_CHARS] for i in ids[:_MAX_PLUGINS_PER_FONT]
if isinstance(i, str) and i})
if clean:
fonts[key[:_MAX_ID_CHARS]] = clean
generated_at = snapshot.get('generated_at')
return {
'generated_at': generated_at if isinstance(generated_at, str) else None,
'fonts': fonts,
}