fix(scoreboards): load bundled fonts independently of the working directory - #279
Conversation
…ectory
Every font the nine scoreboards draw with ships in the LEDMatrix core and
was named relative to the process cwd, as ImageFont.truetype("assets/
fonts/...") or os.path.join("assets", "fonts", ...). That resolves under
the packaged systemd unit, whose WorkingDirectory is the install root,
and nowhere else.
The failure is silent rather than loud. The load raises, the caller's
except branch catches it, and the scoreboard renders in PIL's default
face -- different metrics, different weight -- instead of the pixel font
its layout was measured against. Anyone running the safety harness, a
unit file written without WorkingDirectory, or a manual run from a home
directory gets a subtly wrong panel and no error.
175 direct loads across 25 modules in 9 plugins now go through a
_resolve_font_path() helper. It follows the same order as the core's own
resolver (ChuckBuilds/LEDMatrix#425): the path as given first, so
behaviour is unchanged wherever it already worked and a configured
absolute path passes straight through; then the core install root; then
the original string, so every existing try/except fallback still fires
exactly as before. It calls the core resolver when present so both repos
keep one definition of "install root", and derives the same root from the
core module's location on older cores.
Done across the whole family rather than per plugin, as the issue asks:
sports.py, game_renderer.py, ufc's fight_renderer.py, and the logo and
headshot downloaders that generate placeholder art at runtime.
Each plugin gets a test that runs from a temporary directory -- first
asserting the bare relative path does not resolve there, so the rest
cannot pass vacuously -- then checks every bundled font it names loads as
a real FreeTypeFont, that an absolute path and an unknown name both come
back unchanged, and that no module still holds a raw cwd-relative load.
The font list is discovered from the source, so a new call site is
covered the day it is added.
Mutation-checked: reverting a truetype call to the literal, reverting a
join, and degrading the resolver to the identity function are all caught.
Verified with the safety harness on all nine plugins at every panel size,
the collision check, and the 85 existing tests in these plugins diffed
against origin/main -- no regressions, and the 4 pre-existing failures
(missing src, emulator, network init) are unchanged.
Closes #240
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
|
Warning Review limit reached
Next review available in: 39 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (44)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 553 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
Codacy flagged the helper's try/except/pass in all 25 modules. Replaced the bare Exception with (ImportError, AttributeError, OSError) -- the three things the block can actually raise -- and returned the original path from the handler instead of falling through a pass. Testing the degraded paths turned up a real ordering bug while doing it. Reading _core_fonts.FontManager directly raised AttributeError on a core that lacks the class, which skipped the install-root derivation below it -- the tier that is meant to be the fallback. Both lookups are getattr now, so the derivation runs whatever shape the core is in. Verified against a core that is absent, one with no FontManager, and one with FontManager but no _resolve_asset_path (the pre-#425 shape): the first returns the path untouched so the caller falls back as before, and the other two resolve through the derived install root. All 25 helpers are byte-identical. Nine font tests, the safety harness on all nine plugins, and the collision check re-run clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Closes #240.
The problem
Every font the nine scoreboards draw with ships in the LEDMatrix core, and every call site named it relative to the process working directory —
ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)andos.path.join("assets", "fonts", font_name). That resolves under the packaged systemd unit, whoseWorkingDirectoryis the install root, and nowhere else.The failure mode is silent rather than loud. The load raises, the caller's
exceptbranch catches it, and the scoreboard renders in PIL's default face — different metrics, different weight — instead of the pixel font its layout was measured against. Nothing in the log says so. It bites the plugin safety harness, a unit file written withoutWorkingDirectory, and any manual run from a home directory.The audit found more than the issue estimated: 175 direct loads across 25 modules in 9 plugins. The issue scoped it to
ImageFont.truetype("assets/fonts/..."), but_load_custom_font_from_element_config— the primary path, not the fallback — buildsos.path.join("assets", "fonts", font_name)with the same dependency, and it guards the load with anos.path.existson that same relative path. Fixing only the literals would have left the configured-font path broken while making the hardcoded fallback work.The fix
A
_resolve_font_path()helper in each affected module, following the same order as the core's own resolver from ChuckBuilds/LEDMatrix#425:_resolve_asset_pathwhen it is there, so both repos keep one definition of "install root", and derives the same root from the core module's own location on older cores.try/exceptfallback still fires exactly as it does today.Applied across the whole family rather than one plugin at a time, as the issue asks:
sports.py,game_renderer.py, ufc'sfight_renderer.py, and the logo/headshot downloaders that generate placeholder art at runtime. UFC's_load_fonttakes its path from config so the literal-matching pass could not reach it; it was wrapped by hand.Not in scope, but worth recording: logo directories (
assets/sports/<sport>_logos) have the same cwd dependency. That is a separate audit and a separate change.Verification
Each plugin gets
test_font_paths_cwd_independent.py, which runs from a temporary directory and first asserts the bare relative path does not resolve there — otherwise the rest of the file would prove nothing. It then checks every bundled font the plugin names loads as a realFreeTypeFontrather than PIL's default, that an absolute path and an unknown name both come back unchanged, and that no module still holds a raw cwd-relative load. The font list is discovered from the source, so a new call site is covered the day it is added.Mutation-checked rather than assumed — reverting a
truetypecall to the literal, reverting a join, and degrading the resolver to the identity function are all caught.Also run:
check_module_collisions.py— clean.origin/main: no regressions. Four failures are pre-existing and unchanged (missingsrc, emulator, network init).Acceptance criteria