chore(deprecation): remove the 35 APIs deprecated for 3.8.0 (#708)

* chore(deprecation): remove the 35 APIs deprecated for 3.8.0

The usage scan (docs/DEPRECATIONS_3.8.md, regenerated 2026-10-01 and
committed here) finds no call or override of any of them in the 46
monorepo plugins or the 8 third-party plugins plugins.json lists; the
only core callers were other deprecated methods removed alongside.

- CacheManager: 13 methods, plus the private helpers only
  has_data_changed used (_has_*_changed, _is_market_open).
- DisplayManager: 7 methods, plus WEATHER_COLORS and the private
  _draw_sun/_cloud/_rain/_snow/_storm helpers only the icon methods used.
- FontManager: 14 methods, plus size_tokens, _save_overrides and
  _clear_plugin_font_cache. font_overrides and _load_overrides stay:
  resolve_font() still applies config/font_overrides.json.
  performance_stats stays: get_font() keeps it and tests read it.
- PluginManager.get_enabled_plugins.

test_deprecation.py pins only the two 3.9.0 markers now; the scanner
tests run against a stand-in core instead of the real markers. The
memory-tier tests read stats through log_memory_cache_stats() and the
component, and the test of the removed _clear_plugin_font_cache goes.
Docs drop the removed methods' reference entries; the Deprecated APIs
table becomes "Removed in 3.8.0". CHANGELOG gains a Removed section.

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

* chore(deprecation): drop the test harness's copies of the removed icon methods

VisualTestDisplayManager still drew weather icons that DisplayManager no
longer has, so a plugin's visual tests could pass on calls that raise
AttributeError on the real display. Its draw_sun/draw_cloud/draw_rain/
draw_snow/draw_weather_icon/draw_text_with_icons, WEATHER_COLORS and the
private helpers go, with the tests that exercised them. The CHANGELOG's
Deprecations entries no longer say nothing is removed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 10:45:28 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 695ff92009
commit 2601cb4cbb
16 changed files with 169 additions and 1118 deletions
-257
View File
@@ -37,7 +37,6 @@ from src.cache.disk_cache import DiskCache
from src.cache.cache_strategy import CacheStrategy
from src.cache.cache_metrics import CacheMetrics
from src.logging_config import get_logger
from src.deprecation import deprecated
# Canonical implementation lives in src.cache.disk_cache; re-exported here
# because this module's docstring documents it and external code may import
@@ -408,122 +407,6 @@ class CacheManager:
"""Get the cache directory path."""
return self.cache_dir
@deprecated("3.8.0")
def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool:
"""Check if data has changed from cached version."""
cached_data = self.load_cache(data_type)
if not cached_data:
return True
if data_type == 'weather':
return self._has_weather_changed(cached_data, new_data)
elif data_type == 'stocks':
return self._has_stocks_changed(cached_data, new_data)
elif data_type == 'stock_news':
return self._has_news_changed(cached_data, new_data)
elif data_type == 'nhl':
return self._has_nhl_changed(cached_data, new_data)
elif data_type == 'mlb':
return self._has_mlb_changed(cached_data, new_data)
return True
def _has_weather_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if weather data has changed."""
# Handle new cache structure where data is nested under 'data' key
if 'data' in cached:
cached = cached['data']
# Handle case where cached data might be the weather data directly
if 'current' in cached:
# This is the new structure with 'current' and 'forecast' keys
current_weather = cached.get('current', {})
if current_weather and 'main' in current_weather and 'weather' in current_weather:
cached_temp = round(current_weather['main']['temp'])
cached_condition = current_weather['weather'][0]['main']
return (cached_temp != new.get('temp') or
cached_condition != new.get('condition'))
# Handle old structure where temp and condition are directly accessible
return (cached.get('temp') != new.get('temp') or
cached.get('condition') != new.get('condition'))
def _has_stocks_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if stock data has changed."""
if not self._is_market_open():
return False
return cached.get('price') != new.get('price')
def _has_news_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if news data has changed."""
# Handle both dictionary and list formats
if isinstance(new, list):
# If new data is a list, cached data should also be a list
if not isinstance(cached, list):
return True
# Compare lengths and content
if len(cached) != len(new):
return True
# Compare titles since they're unique enough for our purposes
cached_titles = set(item.get('title', '') for item in cached)
new_titles = set(item.get('title', '') for item in new)
return cached_titles != new_titles
else:
# Original dictionary format handling
cached_headlines = set(h.get('id') for h in cached.get('headlines', []))
new_headlines = set(h.get('id') for h in new.get('headlines', []))
return not cached_headlines.issuperset(new_headlines)
def _has_nhl_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if NHL data has changed."""
return (cached.get('game_status') != new.get('game_status') or
cached.get('score') != new.get('score'))
def _has_mlb_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if MLB game data has changed."""
if not cached or not new:
return True
# Check if any games have changed status or score
for game_id, new_game in new.items():
cached_game = cached.get(game_id)
if not cached_game:
return True
# Check for score changes
if (new_game['away_score'] != cached_game['away_score'] or
new_game['home_score'] != cached_game['home_score']):
return True
# Check for status changes
if new_game['status'] != cached_game['status']:
return True
# For live games, check inning and count
if new_game['status'] == 'in':
if (new_game['inning'] != cached_game['inning'] or
new_game['inning_half'] != cached_game['inning_half'] or
new_game['balls'] != cached_game['balls'] or
new_game['strikes'] != cached_game['strikes'] or
new_game['bases_occupied'] != cached_game['bases_occupied']):
return True
return False
def _is_market_open(self) -> bool:
"""Check if the US stock market is currently open."""
return self._strategy_component.is_market_open()
@deprecated("3.8.0", "use set()")
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
"""Update cache with new data."""
cache_data = {
# Header first; see DiskCache's stale check.
'timestamp': time.time(),
'data': data,
}
return self.save_cache(data_type, cache_data)
def get(self, key: str, max_age: Optional[int] = 300,
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
"""Get data from cache if it exists and is not stale.
@@ -564,42 +447,6 @@ class CacheManager:
cache_data['data'] = data
self.save_cache(key, cache_data)
@deprecated("3.8.0")
def setup_persistent_cache(self) -> bool:
"""
Set up a persistent cache directory with proper permissions.
This should be run once with sudo to create the directory.
"""
try:
# Try to create /var/cache/ledmatrix with proper permissions
from pathlib import Path
from src.common.permission_utils import (
ensure_directory_permissions,
get_cache_dir_mode
)
cache_dir = '/var/cache/ledmatrix'
cache_dir_path = Path(cache_dir)
ensure_directory_permissions(cache_dir_path, get_cache_dir_mode())
# Set ownership to the real user (not root)
real_user = os.environ.get('SUDO_USER')
if real_user:
import pwd
try:
uid = pwd.getpwnam(real_user).pw_uid
gid = pwd.getpwnam(real_user).pw_gid
os.chown(cache_dir, uid, gid)
self.logger.info(f"Set ownership of {cache_dir} to {real_user}")
except (OSError, KeyError) as e:
self.logger.warning(f"Could not set ownership for {cache_dir}: {e}", exc_info=True)
self.logger.info(f"Successfully set up persistent cache directory: {cache_dir}")
return True
except (OSError, IOError, PermissionError) as e:
self.logger.error(f"Failed to set up persistent cache directory {cache_dir}: {e}", exc_info=True)
return False
def cleanup_disk_cache(self, force: bool = False) -> Dict[str, Any]:
"""
Clean up expired disk cache files based on retention policies.
@@ -776,14 +623,6 @@ class CacheManager:
else:
self.logger.info("Disk cache cleanup thread stopped successfully")
@deprecated("3.8.0")
def get_sport_live_interval(self, sport_key: str) -> int:
"""
Get the live_update_interval for a specific sport from config.
Falls back to default values if config is not available.
"""
return self._strategy_component.get_sport_live_interval(sport_key)
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
"""
Get cache strategy for different data types.
@@ -798,13 +637,6 @@ class CacheManager:
"""
return self._strategy_component.get_data_type_from_key(key)
@deprecated("3.8.0")
def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:
"""
Extract sport key from cache key to determine appropriate live_update_interval.
"""
return self._strategy_component.get_sport_key_from_cache_key(key)
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Dict[str, Any]]:
"""
Get data from cache using data-type-specific strategy.
@@ -838,58 +670,6 @@ class CacheManager:
data_type = self.get_data_type_from_key(key)
return self.get_cached_data_with_strategy(key, data_type)
@deprecated("3.8.0", "use get()")
def get_background_cached_data(self, key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Get data from background service cache with appropriate strategy.
This method is specifically designed for Recent/Upcoming managers
to use data cached by the background service.
Args:
key: Cache key to retrieve
sport_key: Sport key for determining appropriate cache strategy
Returns:
Cached data if available and fresh, None otherwise
"""
# Determine the appropriate cache strategy
data_type = self.get_data_type_from_key(key)
strategy = self.get_cache_strategy(data_type, sport_key)
# For Recent/Upcoming managers, we want to use the background service cache
# which should have longer TTLs than the individual manager caches
max_age = strategy['max_age']
memory_ttl = strategy.get('memory_ttl', max_age)
# Get the cached data
cached_data = self.get_cached_data(key, max_age, memory_ttl)
if cached_data:
# Record cache hit for performance monitoring
self.record_cache_hit('background')
# Unwrap if stored in { 'data': ..., 'timestamp': ... } format
if isinstance(cached_data, dict) and 'data' in cached_data:
return cached_data['data']
return cached_data
# Record cache miss for performance monitoring
self.record_cache_miss('background')
return None
@deprecated("3.8.0", "use get()")
def is_background_data_available(self, key: str, sport_key: Optional[str] = None) -> bool:
"""
Check if background service has fresh data available.
This helps Recent/Upcoming managers determine if they should
wait for background data or fetch immediately.
"""
data_type = self.get_data_type_from_key(key)
strategy = self.get_cache_strategy(data_type, sport_key)
# Check if we have data that's still fresh according to background service TTL
cached_data = self.get_cached_data(key, strategy['max_age'])
return cached_data is not None
def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str:
"""
Centralized cache key generation for sports data.
@@ -906,45 +686,8 @@ class CacheManager:
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
return f"{sport}_{date_str}"
@deprecated("3.8.0")
def record_cache_hit(self, cache_type: str = 'regular') -> None:
"""Record a cache hit for performance monitoring."""
self._metrics_component.record_hit(cache_type)
@deprecated("3.8.0")
def record_cache_miss(self, cache_type: str = 'regular') -> None:
"""Record a cache miss for performance monitoring."""
self._metrics_component.record_miss(cache_type)
@deprecated("3.8.0")
def record_fetch_time(self, duration: float) -> None:
"""Record fetch operation duration for performance monitoring."""
self._metrics_component.record_fetch_time(duration)
@deprecated("3.8.0")
def get_cache_metrics(self) -> Dict[str, Any]:
"""Get current cache performance metrics."""
return self._metrics_component.get_metrics()
@deprecated("3.8.0")
def log_cache_metrics(self) -> None:
"""Log current cache performance metrics."""
self._metrics_component.log_metrics()
@deprecated("3.8.0")
def get_memory_cache_stats(self) -> Dict[str, Any]:
"""
Get statistics about the memory cache.
Returns:
Dictionary with memory cache statistics
"""
return self._memory_cache_component.get_stats()
def log_memory_cache_stats(self) -> None:
"""Log current memory cache statistics."""
# Not get_memory_cache_stats(): that is deprecated, and core must not
# trip its own deprecation warning every time memory logging runs.
stats = self._memory_cache_component.get_stats()
self.logger.info(f"Memory Cache - Size: {stats['size']}/{stats['max_size']} "
f"({stats['usage_percent']:.1f}%), "
+1 -212
View File
@@ -51,8 +51,7 @@ from src.pi5_matrix_support import is_raspberry_pi_5
import threading
import time
from collections import OrderedDict, deque
from typing import Dict, Any, List, Optional, Tuple, TYPE_CHECKING
import math
from typing import Dict, Any, Optional, Tuple, TYPE_CHECKING
import zlib
import freetype
@@ -62,7 +61,6 @@ from src.common.frame_timing import FrameTimingRecorder
if TYPE_CHECKING:
from src.common.render_gate import RenderGate
from src.deprecation import deprecated
from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_directory_permissions,
@@ -1323,203 +1321,6 @@ class DisplayManager:
except Exception as e:
logger.error(f"Error drawing text: {e}", exc_info=True)
@deprecated("3.8.0")
def draw_sun(self, x: int, y: int, size: int = 16):
"""Draw a sun icon using yellow circles and lines."""
center = (x + size//2, y + size//2)
radius = size//3
# Draw the center circle
self.draw.ellipse([center[0]-radius, center[1]-radius,
center[0]+radius, center[1]+radius],
fill=(255, 255, 0)) # Yellow
# Draw the rays
ray_length = size//4
for angle in range(0, 360, 45):
rad = math.radians(angle)
start_x = center[0] + (radius * math.cos(rad))
start_y = center[1] + (radius * math.sin(rad))
end_x = center[0] + ((radius + ray_length) * math.cos(rad))
end_y = center[1] + ((radius + ray_length) * math.sin(rad))
self.draw.line([start_x, start_y, end_x, end_y], fill=(255, 255, 0), width=2)
@deprecated("3.8.0")
def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)):
"""Draw a cloud icon."""
# Draw multiple circles to form a cloud shape
self.draw.ellipse([x+size//4, y+size//3, x+size//4+size//2, y+size//3+size//2], fill=color)
self.draw.ellipse([x+size//2, y+size//3, x+size//2+size//2, y+size//3+size//2], fill=color)
self.draw.ellipse([x+size//3, y+size//6, x+size//3+size//2, y+size//6+size//2], fill=color)
@deprecated("3.8.0")
def draw_rain(self, x: int, y: int, size: int = 16):
"""Draw rain icon with cloud and droplets."""
# Draw cloud
self.draw_cloud(x, y, size)
# Draw rain drops
drop_color = (0, 0, 255) # Blue
drop_size = size//6
for i in range(3):
drop_x = x + size//4 + (i * size//3)
drop_y = y + size//2
self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size],
fill=drop_color, width=2)
@deprecated("3.8.0")
def draw_snow(self, x: int, y: int, size: int = 16):
"""Draw snow icon with cloud and snowflakes."""
# Draw cloud
self.draw_cloud(x, y, size)
# Draw snowflakes
snow_color = (200, 200, 255) # Light blue
for i in range(3):
center_x = x + size//4 + (i * size//3)
center_y = y + size//2 + size//4
# Draw a small star shape
for angle in range(0, 360, 60):
rad = math.radians(angle)
end_x = center_x + (size//8 * math.cos(rad))
end_y = center_y + (size//8 * math.sin(rad))
self.draw.line([center_x, center_y, end_x, end_y],
fill=snow_color, width=1)
# Weather icon color constants
WEATHER_COLORS = {
'sun': (255, 200, 0), # Bright yellow
'cloud': (200, 200, 200), # Light gray
'rain': (0, 100, 255), # Light blue
'snow': (220, 220, 255), # Ice blue
'storm': (255, 255, 0) # Lightning yellow
}
def _draw_sun(self, x: int, y: int, size: int) -> None:
"""Draw a sun icon with rays."""
center_x, center_y = x + size//2, y + size//2
radius = size//4
ray_length = size//3
# Draw the main sun circle
self.draw.ellipse([center_x - radius, center_y - radius,
center_x + radius, center_y + radius],
fill=self.WEATHER_COLORS['sun'])
# Draw sun rays
for angle in range(0, 360, 45):
rad = math.radians(angle)
start_x = center_x + int((radius + 2) * math.cos(rad))
start_y = center_y + int((radius + 2) * math.sin(rad))
end_x = center_x + int((radius + ray_length) * math.cos(rad))
end_y = center_y + int((radius + ray_length) * math.sin(rad))
self.draw.line([start_x, start_y, end_x, end_y],
fill=self.WEATHER_COLORS['sun'], width=2)
def _draw_cloud(self, x: int, y: int, size: int) -> None:
"""Draw a cloud using multiple circles."""
cloud_color = self.WEATHER_COLORS['cloud']
base_y = y + size//2
# Draw main cloud body (3 overlapping circles)
circle_radius = size//4
positions = [
(x + size//3, base_y), # Left circle
(x + size//2, base_y - size//6), # Top circle
(x + 2*size//3, base_y) # Right circle
]
for cx, cy in positions:
self.draw.ellipse([cx - circle_radius, cy - circle_radius,
cx + circle_radius, cy + circle_radius],
fill=cloud_color)
def _draw_rain(self, x: int, y: int, size: int) -> None:
"""Draw rain drops falling from a cloud."""
self._draw_cloud(x, y, size)
rain_color = self.WEATHER_COLORS['rain']
# Draw rain drops at an angle
drop_size = size//8
drops = [
(x + size//4, y + 2*size//3),
(x + size//2, y + 3*size//4),
(x + 3*size//4, y + 2*size//3)
]
for dx, dy in drops:
# Draw angled rain drops
self.draw.line([dx, dy, dx - drop_size//2, dy + drop_size],
fill=rain_color, width=2)
def _draw_snow(self, x: int, y: int, size: int) -> None:
"""Draw snowflakes falling from a cloud."""
self._draw_cloud(x, y, size)
snow_color = self.WEATHER_COLORS['snow']
# Draw snowflakes
flake_size = size//6
flakes = [
(x + size//4, y + 2*size//3),
(x + size//2, y + 3*size//4),
(x + 3*size//4, y + 2*size//3)
]
for fx, fy in flakes:
# Draw a snowflake (six-pointed star)
for angle in range(0, 360, 60):
rad = math.radians(angle)
end_x = fx + int(flake_size * math.cos(rad))
end_y = fy + int(flake_size * math.sin(rad))
self.draw.line([fx, fy, end_x, end_y],
fill=snow_color, width=1)
def _draw_storm(self, x: int, y: int, size: int) -> None:
"""Draw a storm cloud with lightning bolt."""
self._draw_cloud(x, y, size)
# Draw lightning bolt
bolt_color = self.WEATHER_COLORS['storm']
bolt_points = [
(x + size//2, y + size//2), # Top
(x + 3*size//5, y + 2*size//3), # Middle right
(x + 2*size//5, y + 2*size//3), # Middle left
(x + size//2, y + 5*size//6) # Bottom
]
self.draw.polygon(bolt_points, fill=bolt_color)
@deprecated("3.8.0")
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
"""Draw a weather icon based on the condition."""
if condition.lower() in ['clear', 'sunny']:
self._draw_sun(x, y, size)
elif condition.lower() in ['clouds', 'cloudy', 'partly cloudy']:
self._draw_cloud(x, y, size)
elif condition.lower() in ['rain', 'drizzle', 'shower']:
self._draw_rain(x, y, size)
elif condition.lower() in ['snow', 'sleet', 'hail']:
self._draw_snow(x, y, size)
elif condition.lower() in ['thunderstorm', 'storm']:
self._draw_storm(x, y, size)
else:
self._draw_sun(x, y, size)
# Note: No update_display() here - let the caller handle the update
@deprecated("3.8.0")
def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None,
color: tuple = (255, 255, 255)):
"""Draw text with weather icons at specified positions."""
# Draw the text
self.draw_text(text, x, y, color)
# Draw any icons
if icons:
for icon_type, icon_x, icon_y in icons:
self.draw_weather_icon(icon_type, icon_x, icon_y)
# Update the display once after everything is drawn
self.update_display()
def cleanup(self):
"""Clean up resources."""
if hasattr(self, '_snapshot_cond'):
@@ -1831,18 +1632,6 @@ class DisplayManager:
if removed_count > 0:
logger.debug(f"Cleaned up {removed_count} expired deferred updates")
@deprecated("3.8.0")
def get_scrolling_stats(self) -> dict:
"""Get current scrolling statistics for debugging."""
return {
'is_scrolling': self._scrolling_state['is_scrolling'],
'last_activity': self._scrolling_state['last_scroll_activity'],
'deferred_count': len(self._scrolling_state['deferred_updates']),
'inactivity_threshold': self._scrolling_state['scroll_inactivity_threshold'],
'max_deferred_updates': self._scrolling_state['max_deferred_updates'],
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
}
def _viewer_is_fresh(self, now: float) -> bool:
"""True when a browser preview is watching (marker file touched by
the web SSE broadcaster). The marker is stat'd at most once per
+4 -240
View File
@@ -40,13 +40,7 @@ from pathlib import Path
from PIL import ImageFont
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
from src.common.font_layout import load_truetype, resolve_asset_path
from src.common.permission_utils import (
ensure_directory_permissions,
get_assets_dir_mode,
get_config_dir_mode,
)
from typing import Dict, Tuple, Optional, Union, Any, List
from src.deprecation import deprecated
from typing import Dict, Tuple, Optional, Union, Any
logger = logging.getLogger(__name__)
@@ -93,7 +87,7 @@ class FontManager:
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
self.temp_font_dir.mkdir(exist_ok=True)
# Counters behind get_performance_stats().
# Font-load counters, kept up by get_font().
self.performance_stats = {
"cache_hits": 0,
"cache_misses": 0,
@@ -109,12 +103,8 @@ class FontManager:
"tom_thumb": "assets/fonts/tom-thumb.bdf"
}
# Size tokens for convenience
self.size_tokens = {
"xs": 6, "sm": 8, "md": 10, "lg": 12, "xl": 14, "xxl": 16
}
# Font overrides storage (for manual overrides)
# Per-element overrides read from config/font_overrides.json;
# resolve_font applies them.
# Under the install root's config/ (which always exists), not the
# cwd: the file itself may not exist yet, and resolve_asset_path
# hands back a missing path unchanged.
@@ -187,26 +177,6 @@ class FontManager:
if removed:
self.manager_fonts_version += 1
@deprecated("3.8.0")
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
"""
Get registered fonts for a specific manager or all managers.
Args:
manager_id: Optional manager ID, if None returns all
Returns:
Dictionary of registered fonts
"""
if manager_id:
return self.manager_fonts.get(manager_id, {})
return self.manager_fonts.copy()
@deprecated("3.8.0")
def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]:
"""Get all detected font usage across managers."""
return self.detected_fonts.copy()
# ==================== Plugin Font Management ====================
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any],
@@ -433,51 +403,6 @@ class FontManager:
search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))]
return resolve_plugin_dir(plugin_id, search_dirs, prefix=True)
@deprecated("3.8.0")
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
"""Unregister all fonts for a plugin."""
try:
if plugin_id in self.plugin_fonts:
# Remove from plugin catalogs
if plugin_id in self.plugin_font_catalogs:
for family in self.plugin_font_catalogs[plugin_id]:
namespaced_family = f"{plugin_id}::{family}"
if namespaced_family in self.font_catalog:
del self.font_catalog[namespaced_family]
del self.plugin_font_catalogs[plugin_id]
# Remove plugin manifest
del self.plugin_fonts[plugin_id]
# Clear related cache entries
self._clear_plugin_font_cache(plugin_id)
logger.info(f"Unregistered fonts for plugin {plugin_id}")
return True
return False
except Exception as e:
logger.error(f"Error unregistering plugin fonts: {e}")
return False
def _clear_plugin_font_cache(self, plugin_id: str):
"""Clear font cache entries for a specific plugin."""
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
for key in keys_to_remove:
del self.font_cache[key]
if keys_to_remove:
# Font objects someone may hold were dropped; see cache_generation.
self.cache_generation += 1
@deprecated("3.8.0")
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
"""Get list of font families registered by a plugin."""
if plugin_id in self.plugin_font_catalogs:
return list(self.plugin_font_catalogs[plugin_id].keys())
return []
# ==================== Font Resolution ====================
def resolve_font(self, element_key: str, family: str, size_px: int,
@@ -668,42 +593,6 @@ class FontManager:
logger.error(f"Error getting font height: {e}", exc_info=True)
return 12 # Default height
# ==================== Override Management ====================
@deprecated("3.8.0")
def set_override(self, element_key: str, family: str = None, size_px: int = None):
"""Set font override for a specific element."""
if element_key not in self.font_overrides:
self.font_overrides[element_key] = {}
if family is not None:
self.font_overrides[element_key]["family"] = family
if size_px is not None:
self.font_overrides[element_key]["size_px"] = size_px
# Remove empty overrides
if not self.font_overrides[element_key]:
del self.font_overrides[element_key]
else:
self._save_overrides()
self.clear_cache()
logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}")
@deprecated("3.8.0")
def remove_override(self, element_key: str):
"""Remove font override for a specific element."""
if element_key in self.font_overrides:
del self.font_overrides[element_key]
self._save_overrides()
self.clear_cache()
logger.info(f"Font override removed for {element_key}")
@deprecated("3.8.0")
def get_overrides(self) -> Dict[str, Dict[str, str]]:
"""Get current font overrides."""
return self.font_overrides.copy()
# ==================== Font Discovery ====================
@staticmethod
@@ -765,17 +654,6 @@ class FontManager:
logger.warning(f"Could not load font overrides: {e}")
self.font_overrides = {}
def _save_overrides(self):
"""Save current font overrides to file."""
try:
font_overrides_path = Path(self.font_overrides_file)
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
with open(self.font_overrides_file, 'w') as f:
json.dump(self.font_overrides, f, indent=2)
logger.info(f"Saved {len(self.font_overrides)} font overrides")
except Exception as e:
logger.error(f"Could not save font overrides: {e}")
# ==================== Utility Methods ====================
def clear_cache(self):
@@ -786,117 +664,3 @@ class FontManager:
# without the bump they kept serving results for the dropped fonts.
self.cache_generation += 1
logger.info("Font cache cleared")
@deprecated("3.8.0", "read font_catalog")
def get_available_fonts(self) -> Dict[str, str]:
"""Get dictionary of available font families and their paths."""
return self.font_catalog.copy()
@deprecated("3.8.0")
def get_size_tokens(self) -> Dict[str, int]:
"""Get available size tokens."""
return self.size_tokens.copy()
@deprecated("3.8.0")
def get_performance_stats(self) -> Dict[str, Any]:
"""Get performance statistics."""
uptime = time.time() - self.performance_stats["start_time"]
return {
"uptime_seconds": uptime,
"cache_hits": self.performance_stats["cache_hits"],
"cache_misses": self.performance_stats["cache_misses"],
"cache_hit_rate": (
self.performance_stats["cache_hits"] /
(self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"])
if (self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"]) > 0 else 0
),
"total_fonts_cached": len(self.font_cache),
"total_metrics_cached": len(self.metrics_cache),
"failed_loads": self.performance_stats["failed_loads"],
"total_fonts_available": len(self.font_catalog),
"plugin_fonts": len(self.plugin_fonts),
"manager_fonts": len(self.manager_fonts),
"detected_fonts": len(self.detected_fonts)
}
@deprecated("3.8.0", "read font_catalog")
def get_font_catalog(self) -> Dict[str, str]:
"""Get the current font catalog."""
return self.font_catalog.copy()
@deprecated("3.8.0")
def add_font(self, font_file_path: str, family_name: str) -> bool:
"""Add ``font_file_path`` to the catalog as ``family_name``. The file
stays where it is; only assets/fonts is created if it is missing."""
try:
# Validate font file
if not os.path.exists(font_file_path):
logger.error(f"Font file not found: {font_file_path}")
return False
# Check if family name already exists
if family_name in self.font_catalog:
logger.warning(f"Font family '{family_name}' already exists")
return False
fonts_dir = Path(resolve_asset_path("assets/fonts"))
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
# Add to catalog
self.font_catalog[family_name] = font_file_path
self.clear_cache()
logger.info(f"Added font {family_name}: {font_file_path}")
return True
except Exception as e:
logger.error(f"Error adding font {family_name}: {e}")
return False
@deprecated("3.8.0")
def remove_font(self, family_name: str) -> bool:
"""Remove a font from the catalog."""
try:
if family_name not in self.font_catalog:
logger.warning(f"Font family '{family_name}' not found")
return False
# Check if font is currently in use
in_use = False
for override in self.font_overrides.values():
if override.get("family") == family_name:
in_use = True
break
if in_use:
logger.error(f"Cannot remove font '{family_name}' - it is currently in use")
return False
del self.font_catalog[family_name]
self.clear_cache()
logger.info(f"Removed font {family_name}")
return True
except Exception as e:
logger.error(f"Error removing font {family_name}: {e}")
return False
@deprecated("3.8.0")
def validate_font(self, font_path: str) -> Dict[str, Any]:
"""Validate a font file."""
try:
if not os.path.exists(font_path):
return {"valid": False, "error": "Font file not found"}
if font_path.endswith('.bdf'):
# Try to load BDF font
freetype.Face(font_path)
return {"valid": True, "type": "bdf", "family": "unknown"}
elif font_path.endswith('.ttf'):
# Try to load TTF font
load_truetype(font_path, 12)
return {"valid": True, "type": "ttf", "family": "unknown"}
else:
return {"valid": False, "error": "Unsupported font format"}
except Exception as e:
return {"valid": False, "error": str(e)}
-11
View File
@@ -32,7 +32,6 @@ from src.plugin_system.schema_manager import (
from src.plugin_system.plugin_dirs import (
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
)
from src.deprecation import deprecated
from src.common.permission_utils import (
ensure_directory_permissions,
get_plugin_dir_mode
@@ -873,16 +872,6 @@ class PluginManager:
"""
return self.plugins.copy()
@deprecated("3.8.0", "check each plugin's enabled flag in plugins")
def get_enabled_plugins(self) -> List[str]:
"""
Get list of enabled plugin IDs.
Returns:
List of plugin IDs that are currently enabled
"""
return [pid for pid, plugin in self.plugins.items() if plugin.enabled]
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
"""
Get information about a plugin (manifest + runtime info).
@@ -14,9 +14,7 @@ PIL Image canvas and draws text using the actual project fonts.
MAINTENANCE WARNING: this class is a deliberate fork of
src/display_manager.py so it can run without hardware. It mirrors
these DisplayManager methods by name and behavior: _load_fonts,
get_font_height, get_text_width, draw_text,
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
get_font_height, get_text_width, draw_text, format_date_with_ordinal,
capture_mode, set_scrolling_state, is_currently_scrolling,
process_deferred_updates, update_display, render_size, offscreen. A behavior
change to any of those in DisplayManager must be mirrored here, or
@@ -26,13 +24,12 @@ BDF text is not mirrored: both classes load BDF faces and draw BDF glyphs
through src/common/bdf_font.py, so those pixels cannot drift.
"""
import math
import os
import time
import warnings
from contextlib import contextmanager
from pathlib import Path
from typing import Any, List, Optional, Tuple
from typing import Any, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from src.common.bdf_font import draw_bdf_text, load_bdf_face
@@ -63,15 +60,6 @@ class VisualTestDisplayManager:
no emulator dependency.
"""
# Weather icon color constants (same as DisplayManager)
WEATHER_COLORS = {
'sun': (255, 200, 0),
'cloud': (200, 200, 200),
'rain': (0, 100, 255),
'snow': (220, 220, 255),
'storm': (255, 255, 0),
}
def __init__(self, width: int = 128, height: int = 32):
self._width = width
self._height = height
@@ -410,129 +398,6 @@ class VisualTestDisplayManager:
return font.size
return 8
# ------------------------------------------------------------------
# Weather drawing helpers
# ------------------------------------------------------------------
def draw_sun(self, x: int, y: int, size: int = 16):
"""Draw a sun icon using yellow circles and lines."""
self._draw_sun(x, y, size)
def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):
"""Draw a cloud icon."""
self._draw_cloud(x, y, size, color)
def draw_rain(self, x: int, y: int, size: int = 16):
"""Draw rain icon with cloud and droplets."""
self._draw_rain(x, y, size)
def draw_snow(self, x: int, y: int, size: int = 16):
"""Draw snow icon with cloud and snowflakes."""
self._draw_snow(x, y, size)
def _draw_sun(self, x: int, y: int, size: int) -> None:
"""Draw a sun icon with rays (internal weather icon version)."""
center_x, center_y = x + size // 2, y + size // 2
radius = size // 4
ray_length = size // 3
self.draw.ellipse(
[center_x - radius, center_y - radius,
center_x + radius, center_y + radius],
fill=self.WEATHER_COLORS['sun'],
)
for angle in range(0, 360, 45):
rad = math.radians(angle)
start_x = center_x + int((radius + 2) * math.cos(rad))
start_y = center_y + int((radius + 2) * math.sin(rad))
end_x = center_x + int((radius + ray_length) * math.cos(rad))
end_y = center_y + int((radius + ray_length) * math.sin(rad))
self.draw.line([start_x, start_y, end_x, end_y], fill=self.WEATHER_COLORS['sun'], width=2)
def _draw_cloud(self, x: int, y: int, size: int, color: Optional[Tuple[int, int, int]] = None) -> None:
"""Draw a cloud using multiple circles (internal weather icon version)."""
cloud_color = color if color is not None else self.WEATHER_COLORS['cloud']
base_y = y + size // 2
circle_radius = size // 4
positions = [
(x + size // 3, base_y),
(x + size // 2, base_y - size // 6),
(x + 2 * size // 3, base_y),
]
for cx, cy in positions:
self.draw.ellipse(
[cx - circle_radius, cy - circle_radius,
cx + circle_radius, cy + circle_radius],
fill=cloud_color,
)
def _draw_rain(self, x: int, y: int, size: int) -> None:
"""Draw rain drops falling from a cloud."""
self._draw_cloud(x, y, size)
rain_color = self.WEATHER_COLORS['rain']
drop_size = size // 8
drops = [
(x + size // 4, y + 2 * size // 3),
(x + size // 2, y + 3 * size // 4),
(x + 3 * size // 4, y + 2 * size // 3),
]
for dx, dy in drops:
self.draw.line([dx, dy, dx - drop_size // 2, dy + drop_size], fill=rain_color, width=2)
def _draw_snow(self, x: int, y: int, size: int) -> None:
"""Draw snowflakes falling from a cloud."""
self._draw_cloud(x, y, size)
snow_color = self.WEATHER_COLORS['snow']
flake_size = size // 6
flakes = [
(x + size // 4, y + 2 * size // 3),
(x + size // 2, y + 3 * size // 4),
(x + 3 * size // 4, y + 2 * size // 3),
]
for fx, fy in flakes:
for angle in range(0, 360, 60):
rad = math.radians(angle)
end_x = fx + int(flake_size * math.cos(rad))
end_y = fy + int(flake_size * math.sin(rad))
self.draw.line([fx, fy, end_x, end_y], fill=snow_color, width=1)
def _draw_storm(self, x: int, y: int, size: int) -> None:
"""Draw a storm cloud with lightning bolt."""
self._draw_cloud(x, y, size)
bolt_color = self.WEATHER_COLORS['storm']
bolt_points = [
(x + size // 2, y + size // 2),
(x + 3 * size // 5, y + 2 * size // 3),
(x + 2 * size // 5, y + 2 * size // 3),
(x + size // 2, y + 5 * size // 6),
]
self.draw.polygon(bolt_points, fill=bolt_color)
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
"""Draw a weather icon based on the condition."""
cond = condition.lower()
if cond in ('clear', 'sunny'):
self._draw_sun(x, y, size)
elif cond in ('clouds', 'cloudy', 'partly cloudy'):
self._draw_cloud(x, y, size)
elif cond in ('rain', 'drizzle', 'shower'):
self._draw_rain(x, y, size)
elif cond in ('snow', 'sleet', 'hail'):
self._draw_snow(x, y, size)
elif cond in ('thunderstorm', 'storm'):
self._draw_storm(x, y, size)
else:
self._draw_sun(x, y, size)
def draw_text_with_icons(self, text: str, icons: List[tuple] = None,
x: int = None, y: int = None,
color: tuple = (255, 255, 255)):
"""Draw text with weather icons at specified positions."""
self.draw_text(text, x, y, color)
if icons:
for icon_type, icon_x, icon_y in icons:
self.draw_weather_icon(icon_type, icon_x, icon_y)
self.update_display()
# ------------------------------------------------------------------
# Scrolling state (no-op interface compat)
# ------------------------------------------------------------------