| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976 |
- """
- Asset Manager for Trixy Application
- This module provides the main AssetManager class that implements profile-based
- asset loading with fallback to default assets as specified in CLAUDE.md.
- Features:
- - Profile-based asset loading: ./assets/{profile}/path → ./assets/default/path
- - Asset caching for improved performance
- - Asset validation and integrity checking
- - Hot-reload capability for development
- - Asset dependency tracking
- - Thread-safe operations for multi-satellite support
- - Integration with application container and configuration system
- - Comprehensive logging using pprint() function
- The AssetManager is designed to handle various asset types:
- - Audio files (success.wav, error.wav, notification sounds)
- - Image assets (icons, backgrounds, status indicators)
- - Text files (templates, messages, responses)
- - Configuration templates (default settings, profiles)
- Usage:
- from trixy_core.assets import AssetManager
-
- # Initialize with application container
- asset_manager = AssetManager(application_container)
-
- # Get asset path with profile fallback
- audio_path = asset_manager.get_path("audio/success.wav")
-
- # Enable hot-reload for development
- asset_manager.enable_hot_reload(True)
-
- # Preload assets for better performance
- asset_manager.preload_assets(["audio/*.wav", "images/*.png"])
- """
- import os
- import hashlib
- import threading
- import time
- import weakref
- from pathlib import Path
- from typing import Dict, Any, Optional, List, Set, Union, Callable, Tuple
- from dataclasses import dataclass, field
- from enum import Enum
- import json
- import re
- import mimetypes
- from concurrent.futures import ThreadPoolExecutor
- from contextlib import contextmanager
- from watchdog.observers import Observer
- from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent, FileDeletedEvent
- # Import required types and utilities
- try:
- from ..config import ConfigManager, DeploymentMode
- from ..events import EventHandler, TrixyEvent
- from .asset_types import AssetType, AssetMetadata, get_asset_type_from_path, validate_asset_type
- from .asset_cache import AssetCache, CacheEntry, CachePolicy
- from .asset_validator import AssetValidator, AssetValidationResult, AssetIntegrityChecker
- except ImportError:
- # Handle missing imports gracefully for initial setup
- ConfigManager = None
- EventHandler = None
- TrixyEvent = None
- AssetType = None
- AssetMetadata = None
- AssetCache = None
- AssetValidator = None
- class AssetLoadError(Exception):
- """Raised when asset loading fails."""
- pass
- class AssetValidationError(Exception):
- """Raised when asset validation fails."""
- pass
- class AssetNotFoundError(Exception):
- """Raised when asset is not found in any location."""
- pass
- class AssetManagerError(Exception):
- """Base exception for asset manager errors."""
- pass
- class AssetStatus(Enum):
- """Status of asset loading/validation."""
- UNKNOWN = "unknown"
- LOADING = "loading"
- LOADED = "loaded"
- CACHED = "cached"
- FAILED = "failed"
- NOT_FOUND = "not_found"
- VALIDATING = "validating"
- VALID = "valid"
- INVALID = "invalid"
- class ProfileMode(Enum):
- """Profile loading modes."""
- STRICT = "strict" # Only load from profile, no fallback
- FALLBACK = "fallback" # Load from profile, fallback to default
- DEFAULT_ONLY = "default_only" # Only load from default profile
- @dataclass
- class AssetInfo:
- """Information about a loaded asset."""
- path: str
- full_path: str
- profile: str
- asset_type: Optional[str] = None
- size: int = 0
- last_modified: float = 0.0
- checksum: Optional[str] = None
- status: AssetStatus = AssetStatus.UNKNOWN
- load_time: float = 0.0
- access_count: int = 0
- last_access: float = 0.0
- metadata: Dict[str, Any] = field(default_factory=dict)
- dependencies: Set[str] = field(default_factory=set)
-
- def __post_init__(self):
- """Initialize computed fields."""
- if self.last_access == 0.0:
- self.last_access = time.time()
- @dataclass
- class AssetManagerConfig:
- """Configuration for AssetManager."""
- assets_directory: str = "./assets"
- default_profile: str = "default"
- current_profile: Optional[str] = None
- cache_enabled: bool = True
- cache_max_size: int = 100 # Maximum number of cached assets
- cache_max_memory: int = 100 * 1024 * 1024 # 100MB cache limit
- validation_enabled: bool = True
- hot_reload_enabled: bool = False
- hot_reload_debounce: float = 0.5 # Debounce time for file changes
- preload_patterns: List[str] = field(default_factory=list)
- profile_mode: ProfileMode = ProfileMode.FALLBACK
- thread_pool_size: int = 4
- enable_integrity_checks: bool = True
- auto_create_directories: bool = True
- compress_cached_assets: bool = False
- asset_timeout: float = 30.0 # Timeout for asset loading operations
- class AssetWatcher(FileSystemEventHandler):
- """File system watcher for hot-reload functionality."""
-
- def __init__(self, asset_manager: 'AssetManager'):
- super().__init__()
- self._asset_manager_ref = weakref.ref(asset_manager)
- self._debounce_timers: Dict[str, threading.Timer] = {}
- self._lock = threading.Lock()
-
- def on_modified(self, event):
- """Handle file modification events."""
- if event.is_directory:
- return
- self._handle_file_change(event.src_path, "modified")
-
- def on_created(self, event):
- """Handle file creation events."""
- if event.is_directory:
- return
- self._handle_file_change(event.src_path, "created")
-
- def on_deleted(self, event):
- """Handle file deletion events."""
- if event.is_directory:
- return
- self._handle_file_change(event.src_path, "deleted")
-
- def _handle_file_change(self, file_path: str, change_type: str):
- """Handle file system changes with debouncing."""
- asset_manager = self._asset_manager_ref()
- if asset_manager is None:
- return
-
- with self._lock:
- # Cancel existing timer for this file
- if file_path in self._debounce_timers:
- self._debounce_timers[file_path].cancel()
-
- # Create new debounced timer
- timer = threading.Timer(
- asset_manager.config.hot_reload_debounce,
- lambda: asset_manager._handle_asset_change(file_path, change_type)
- )
- self._debounce_timers[file_path] = timer
- timer.start()
- def pprint(message: str) -> None:
- """
- Asset manager logging function that adapts based on mode.
- Uses the same pattern as specified in CLAUDE.md.
- """
- print(f"[ASSET_MANAGER] {message}")
- class AssetManager:
- """
- Professional asset management system for Trixy application.
-
- Provides profile-based asset loading with fallback to default assets,
- comprehensive caching, validation, and hot-reload capabilities.
-
- Key features:
- - Profile-based loading: ./assets/{profile}/path → ./assets/default/path
- - Asset caching with configurable policies
- - Asset validation and integrity checking
- - Hot-reload for development
- - Thread-safe operations
- - Asset dependency tracking
- - Integration with application container
- """
-
- def __init__(
- self,
- application_container=None,
- config: Optional[AssetManagerConfig] = None,
- assets_directory: Optional[str] = None,
- profile: Optional[str] = None
- ):
- """
- Initialize the AssetManager.
-
- Args:
- application_container: Application container for dependency injection
- config: Asset manager configuration
- assets_directory: Override assets directory path
- profile: Override current profile
- """
- # Store application container reference
- self.application = application_container
- self.application_ref = weakref.ref(application_container) if application_container else None
-
- # Initialize configuration
- self.config = config or AssetManagerConfig()
- if assets_directory:
- self.config.assets_directory = assets_directory
- if profile:
- self.config.current_profile = profile
-
- # Initialize state
- self._assets: Dict[str, AssetInfo] = {}
- self._assets_lock = threading.RLock()
- self._startup_time = time.time()
- self._stats = {
- 'total_loads': 0,
- 'cache_hits': 0,
- 'cache_misses': 0,
- 'validation_failures': 0,
- 'hot_reload_events': 0
- }
-
- # Initialize components
- self._cache: Optional[AssetCache] = None
- self._validator: Optional[AssetValidator] = None
- self._integrity_checker: Optional[AssetIntegrityChecker] = None
- self._thread_pool: Optional[ThreadPoolExecutor] = None
- self._watcher: Optional[Observer] = None
- self._asset_watcher: Optional[AssetWatcher] = None
-
- # Initialize logging
- pprint(f"AssetManager initialized with profile: {self.config.current_profile or 'default'}")
-
- # Initialize components and validate setup
- self._initialize_components()
- self._validate_setup()
-
- # Preload assets if configured
- if self.config.preload_patterns:
- self._preload_assets()
-
- def _initialize_components(self) -> None:
- """Initialize asset manager components."""
- try:
- # Initialize thread pool
- self._thread_pool = ThreadPoolExecutor(
- max_workers=self.config.thread_pool_size,
- thread_name_prefix="asset_manager"
- )
-
- # Initialize cache if enabled
- if self.config.cache_enabled and AssetCache:
- cache_policy = CachePolicy(
- max_entries=self.config.cache_max_size,
- max_memory_bytes=self.config.cache_max_memory,
- compress=self.config.compress_cached_assets
- )
- self._cache = AssetCache(policy=cache_policy)
- pprint("Asset cache initialized")
-
- # Initialize validator if enabled and available
- if self.config.validation_enabled and AssetValidator:
- self._validator = AssetValidator()
- pprint("Asset validator initialized")
-
- # Initialize integrity checker if enabled and available
- if self.config.enable_integrity_checks and AssetIntegrityChecker:
- self._integrity_checker = AssetIntegrityChecker()
- pprint("Asset integrity checker initialized")
-
- # Initialize hot-reload watcher if enabled
- if self.config.hot_reload_enabled:
- self._setup_hot_reload()
-
- pprint("Asset manager components initialized")
-
- except Exception as e:
- pprint(f"Error initializing asset manager components: {e}")
- # Continue with limited functionality
-
- def _validate_setup(self) -> None:
- """Validate asset manager setup and configuration."""
- assets_path = Path(self.config.assets_directory)
- default_path = assets_path / self.config.default_profile
-
- # Create directories if needed and allowed
- if self.config.auto_create_directories:
- try:
- default_path.mkdir(parents=True, exist_ok=True)
- (default_path / "audio").mkdir(exist_ok=True)
- (default_path / "images").mkdir(exist_ok=True)
- (default_path / "text").mkdir(exist_ok=True)
- (default_path / "config").mkdir(exist_ok=True)
- pprint(f"Created default asset directories at: {default_path}")
- except Exception as e:
- pprint(f"Warning: Could not create asset directories: {e}")
-
- # Validate assets directory exists
- if not assets_path.exists():
- pprint(f"Warning: Assets directory does not exist: {assets_path}")
- elif not default_path.exists():
- pprint(f"Warning: Default profile directory does not exist: {default_path}")
-
- # Log current configuration
- pprint(f"Assets directory: {self.config.assets_directory}")
- pprint(f"Default profile: {self.config.default_profile}")
- pprint(f"Current profile: {self.config.current_profile or 'none (using default)'}")
- pprint(f"Profile mode: {self.config.profile_mode.value}")
- pprint(f"Cache enabled: {self.config.cache_enabled}")
- pprint(f"Validation enabled: {self.config.validation_enabled}")
- pprint(f"Hot-reload enabled: {self.config.hot_reload_enabled}")
-
- def _setup_hot_reload(self) -> None:
- """Setup hot-reload file system watcher."""
- try:
- from watchdog.observers import Observer
-
- self._asset_watcher = AssetWatcher(self)
- self._watcher = Observer()
-
- # Watch assets directory
- assets_path = Path(self.config.assets_directory)
- if assets_path.exists():
- self._watcher.schedule(self._asset_watcher, str(assets_path), recursive=True)
- self._watcher.start()
- pprint(f"Hot-reload watcher started for: {assets_path}")
- else:
- pprint(f"Warning: Cannot start hot-reload watcher, assets directory not found: {assets_path}")
-
- except ImportError:
- pprint("Warning: Hot-reload requires watchdog package, but it's not available")
- except Exception as e:
- pprint(f"Warning: Failed to setup hot-reload: {e}")
-
- def _preload_assets(self) -> None:
- """Preload assets based on configured patterns."""
- if not self.config.preload_patterns:
- return
-
- pprint(f"Preloading assets with patterns: {self.config.preload_patterns}")
- preload_count = 0
-
- for pattern in self.config.preload_patterns:
- try:
- # Find matching assets
- assets_to_preload = self._find_assets_by_pattern(pattern)
-
- # Preload each asset
- for asset_path in assets_to_preload:
- try:
- self.get_path(asset_path)
- preload_count += 1
- except Exception as e:
- pprint(f"Failed to preload asset {asset_path}: {e}")
-
- except Exception as e:
- pprint(f"Error processing preload pattern {pattern}: {e}")
-
- pprint(f"Preloaded {preload_count} assets")
-
- def _find_assets_by_pattern(self, pattern: str) -> List[str]:
- """Find assets matching a glob pattern."""
- import glob
-
- assets_found = []
- base_path = Path(self.config.assets_directory)
-
- # Check both current profile and default profile
- profiles_to_check = [self.config.default_profile]
- if self.config.current_profile and self.config.current_profile != self.config.default_profile:
- profiles_to_check.insert(0, self.config.current_profile)
-
- for profile in profiles_to_check:
- profile_path = base_path / profile
- if profile_path.exists():
- # Create full pattern path
- full_pattern = str(profile_path / pattern)
-
- # Find matching files
- matches = glob.glob(full_pattern, recursive=True)
-
- # Convert to relative asset paths
- for match in matches:
- try:
- # Convert absolute path to relative asset path
- relative_path = Path(match).relative_to(profile_path)
- asset_path = str(relative_path).replace(os.sep, '/')
-
- # Avoid duplicates
- if asset_path not in assets_found:
- assets_found.append(asset_path)
-
- except ValueError:
- # Path is not relative to profile path
- continue
-
- return assets_found
-
- def get_path(self, asset_path: str) -> Union[str, bool]:
- """
- Get the full file system path for an asset with profile-based fallback.
-
- This is the main method specified in CLAUDE.md for asset resolution:
- - Primary: ./assets/{profile}/path/to/asset
- - Fallback: ./assets/default/path/to/asset
- - Returns False if asset not found in either location
-
- Args:
- asset_path: Relative path to the asset (e.g., "audio/success.wav")
-
- Returns:
- str: Full path to the asset file if found
- bool: False if asset not found in any location
- """
- if not asset_path:
- return False
-
- # Normalize path separators
- asset_path = asset_path.replace('\\', '/')
-
- # Check cache first
- with self._assets_lock:
- if asset_path in self._assets:
- asset_info = self._assets[asset_path]
- asset_info.access_count += 1
- asset_info.last_access = time.time()
-
- # Verify file still exists
- if os.path.exists(asset_info.full_path):
- self._stats['cache_hits'] += 1
- return asset_info.full_path
- else:
- # File was deleted, remove from cache
- del self._assets[asset_path]
- pprint(f"Asset removed from cache (file deleted): {asset_path}")
-
- # Asset not in cache or cache miss
- self._stats['cache_misses'] += 1
- self._stats['total_loads'] += 1
-
- # Try to load asset
- try:
- full_path = self._resolve_asset_path(asset_path)
- if full_path:
- # Create asset info and add to cache
- asset_info = self._create_asset_info(asset_path, full_path)
-
- # Validate asset if enabled
- if self._should_validate_asset(asset_info):
- self._validate_asset(asset_info)
-
- # Store in cache
- with self._assets_lock:
- self._assets[asset_path] = asset_info
-
- return full_path
- else:
- return False
-
- except Exception as e:
- pprint(f"Error loading asset {asset_path}: {e}")
- return False
-
- def _resolve_asset_path(self, asset_path: str) -> Optional[str]:
- """
- Resolve asset path using profile-based fallback logic.
-
- Args:
- asset_path: Relative asset path
-
- Returns:
- Full file system path if found, None otherwise
- """
- base_path = Path(self.config.assets_directory)
-
- # Determine which profiles to check based on mode
- if self.config.profile_mode == ProfileMode.DEFAULT_ONLY:
- profiles_to_check = [self.config.default_profile]
- elif self.config.profile_mode == ProfileMode.STRICT:
- if self.config.current_profile:
- profiles_to_check = [self.config.current_profile]
- else:
- profiles_to_check = [self.config.default_profile]
- else: # FALLBACK mode (default)
- profiles_to_check = [self.config.default_profile]
- if self.config.current_profile and self.config.current_profile != self.config.default_profile:
- profiles_to_check.insert(0, self.config.current_profile)
-
- # Try each profile in order
- for profile in profiles_to_check:
- profile_path = base_path / profile / asset_path
-
- if profile_path.exists() and profile_path.is_file():
- return str(profile_path.resolve())
-
- return None
-
- def _create_asset_info(self, asset_path: str, full_path: str) -> AssetInfo:
- """Create AssetInfo object for a loaded asset."""
- path_obj = Path(full_path)
-
- # Determine profile from full path
- base_path = Path(self.config.assets_directory)
- try:
- relative_to_assets = path_obj.relative_to(base_path)
- profile = relative_to_assets.parts[0] if relative_to_assets.parts else self.config.default_profile
- except ValueError:
- profile = "unknown"
-
- # Get file information
- stat = path_obj.stat()
-
- # Create asset info
- asset_info = AssetInfo(
- path=asset_path,
- full_path=full_path,
- profile=profile,
- size=stat.st_size,
- last_modified=stat.st_mtime,
- status=AssetStatus.LOADED,
- load_time=time.time()
- )
-
- # Determine asset type if available
- if AssetType:
- try:
- asset_info.asset_type = get_asset_type_from_path(asset_path)
- except Exception:
- asset_info.asset_type = None
-
- # Calculate checksum if integrity checking is enabled
- if self.config.enable_integrity_checks:
- try:
- asset_info.checksum = self._calculate_checksum(full_path)
- except Exception as e:
- pprint(f"Warning: Could not calculate checksum for {asset_path}: {e}")
-
- return asset_info
-
- def _should_validate_asset(self, asset_info: AssetInfo) -> bool:
- """Determine if an asset should be validated."""
- return (
- self.config.validation_enabled and
- self._validator is not None and
- asset_info.asset_type is not None
- )
-
- def _validate_asset(self, asset_info: AssetInfo) -> None:
- """Validate an asset and update its status."""
- if not self._validator:
- return
-
- try:
- asset_info.status = AssetStatus.VALIDATING
-
- result = self._validator.validate_asset(
- asset_info.full_path,
- asset_info.asset_type
- )
-
- if result and result.is_valid:
- asset_info.status = AssetStatus.VALID
- if result.metadata:
- asset_info.metadata.update(result.metadata)
- else:
- asset_info.status = AssetStatus.INVALID
- self._stats['validation_failures'] += 1
-
- error_msg = f"Asset validation failed: {asset_info.path}"
- if result and result.errors:
- error_msg += f" - {', '.join(result.errors)}"
- pprint(error_msg)
-
- except Exception as e:
- asset_info.status = AssetStatus.INVALID
- self._stats['validation_failures'] += 1
- pprint(f"Asset validation error for {asset_info.path}: {e}")
-
- def _calculate_checksum(self, file_path: str) -> str:
- """Calculate SHA256 checksum for a file."""
- hash_sha256 = hashlib.sha256()
-
- with open(file_path, 'rb') as f:
- for chunk in iter(lambda: f.read(4096), b""):
- hash_sha256.update(chunk)
-
- return hash_sha256.hexdigest()
-
- def _handle_asset_change(self, file_path: str, change_type: str) -> None:
- """Handle asset file system changes for hot-reload."""
- if not self.config.hot_reload_enabled:
- return
-
- self._stats['hot_reload_events'] += 1
-
- try:
- # Convert file path to asset path
- base_path = Path(self.config.assets_directory)
- file_path_obj = Path(file_path)
-
- # Check if file is within assets directory
- try:
- relative_path = file_path_obj.relative_to(base_path)
-
- # Extract profile and asset path
- if len(relative_path.parts) < 2:
- return # Not a valid asset path
-
- profile = relative_path.parts[0]
- asset_path = '/'.join(relative_path.parts[1:])
-
- pprint(f"Asset {change_type}: {asset_path} (profile: {profile})")
-
- # Handle different change types
- if change_type == "deleted":
- self._handle_asset_deleted(asset_path)
- elif change_type in ["modified", "created"]:
- self._handle_asset_updated(asset_path)
-
- except ValueError:
- # File is not within assets directory
- return
-
- except Exception as e:
- pprint(f"Error handling asset change {file_path}: {e}")
-
- def _handle_asset_deleted(self, asset_path: str) -> None:
- """Handle asset deletion."""
- with self._assets_lock:
- if asset_path in self._assets:
- del self._assets[asset_path]
- pprint(f"Removed deleted asset from cache: {asset_path}")
-
- # Remove from cache if available
- if self._cache:
- self._cache.remove(asset_path)
-
- def _handle_asset_updated(self, asset_path: str) -> None:
- """Handle asset update/creation."""
- with self._assets_lock:
- if asset_path in self._assets:
- # Remove from cache to force reload
- del self._assets[asset_path]
- pprint(f"Invalidated cache for updated asset: {asset_path}")
-
- # Remove from cache if available
- if self._cache:
- self._cache.remove(asset_path)
-
- # Preload asset if it matches preload patterns
- for pattern in self.config.preload_patterns:
- if self._matches_pattern(asset_path, pattern):
- try:
- self.get_path(asset_path)
- pprint(f"Preloaded updated asset: {asset_path}")
- except Exception as e:
- pprint(f"Failed to preload updated asset {asset_path}: {e}")
- break
-
- def _matches_pattern(self, asset_path: str, pattern: str) -> bool:
- """Check if asset path matches a glob pattern."""
- import fnmatch
- return fnmatch.fnmatch(asset_path, pattern)
-
- def get_asset_info(self, asset_path: str) -> Optional[AssetInfo]:
- """
- Get detailed information about an asset.
-
- Args:
- asset_path: Relative path to the asset
-
- Returns:
- AssetInfo object if asset is loaded, None otherwise
- """
- with self._assets_lock:
- return self._assets.get(asset_path)
-
- def list_assets(self, profile: Optional[str] = None) -> List[str]:
- """
- List all available assets in a profile.
-
- Args:
- profile: Profile to list assets from (None for current profile)
-
- Returns:
- List of asset paths
- """
- if profile is None:
- profile = self.config.current_profile or self.config.default_profile
-
- assets = []
- profile_path = Path(self.config.assets_directory) / profile
-
- if profile_path.exists():
- for root, dirs, files in os.walk(profile_path):
- for file in files:
- file_path = Path(root) / file
- try:
- relative_path = file_path.relative_to(profile_path)
- asset_path = str(relative_path).replace(os.sep, '/')
- assets.append(asset_path)
- except ValueError:
- continue
-
- return sorted(assets)
-
- def clear_cache(self) -> None:
- """Clear the asset cache."""
- with self._assets_lock:
- cache_size = len(self._assets)
- self._assets.clear()
- pprint(f"Cleared asset cache ({cache_size} entries)")
-
- if self._cache:
- self._cache.clear()
-
- def preload_assets(self, patterns: List[str]) -> int:
- """
- Preload assets matching the given patterns.
-
- Args:
- patterns: List of glob patterns to match
-
- Returns:
- Number of assets preloaded
- """
- preload_count = 0
-
- for pattern in patterns:
- assets_to_preload = self._find_assets_by_pattern(pattern)
-
- for asset_path in assets_to_preload:
- try:
- result = self.get_path(asset_path)
- if result:
- preload_count += 1
- except Exception as e:
- pprint(f"Failed to preload asset {asset_path}: {e}")
-
- pprint(f"Preloaded {preload_count} assets")
- return preload_count
-
- def get_profile(self) -> str:
- """Get the current asset profile."""
- return self.config.current_profile or self.config.default_profile
-
- def set_profile(self, profile: str) -> None:
- """
- Set the current asset profile.
-
- Args:
- profile: Profile name to set as current
- """
- if profile == self.config.current_profile:
- return
-
- old_profile = self.config.current_profile
- self.config.current_profile = profile
-
- pprint(f"Asset profile changed: {old_profile} → {profile}")
-
- # Clear cache to force reload with new profile
- self.clear_cache()
-
- def enable_hot_reload(self, enabled: bool = True) -> None:
- """
- Enable or disable hot-reload functionality.
-
- Args:
- enabled: Whether to enable hot-reload
- """
- if enabled == self.config.hot_reload_enabled:
- return
-
- self.config.hot_reload_enabled = enabled
-
- if enabled:
- self._setup_hot_reload()
- pprint("Hot-reload enabled")
- else:
- if self._watcher:
- self._watcher.stop()
- self._watcher = None
- self._asset_watcher = None
- pprint("Hot-reload disabled")
-
- def get_stats(self) -> Dict[str, Any]:
- """
- Get asset manager statistics.
-
- Returns:
- Dictionary containing statistics
- """
- with self._assets_lock:
- cache_stats = {
- 'cached_assets': len(self._assets),
- 'cache_memory_usage': sum(info.size for info in self._assets.values()),
- 'total_access_count': sum(info.access_count for info in self._assets.values()),
- }
-
- return {
- **self._stats,
- **cache_stats,
- 'uptime_seconds': time.time() - self._startup_time,
- 'current_profile': self.get_profile(),
- 'cache_enabled': self.config.cache_enabled,
- 'validation_enabled': self.config.validation_enabled,
- 'hot_reload_enabled': self.config.hot_reload_enabled,
- }
-
- def get_health_status(self) -> Dict[str, Any]:
- """
- Get health status of the asset manager.
-
- Returns:
- Dictionary containing health information
- """
- stats = self.get_stats()
-
- return {
- 'status': 'healthy', # Could be enhanced with actual health checks
- 'assets_directory_exists': Path(self.config.assets_directory).exists(),
- 'default_profile_exists': (Path(self.config.assets_directory) / self.config.default_profile).exists(),
- 'current_profile_exists': (Path(self.config.assets_directory) / self.get_profile()).exists(),
- 'cache_hit_rate': stats['cache_hits'] / max(1, stats['cache_hits'] + stats['cache_misses']),
- 'validation_failure_rate': stats['validation_failures'] / max(1, stats['total_loads']),
- 'components': {
- 'cache': self._cache is not None,
- 'validator': self._validator is not None,
- 'integrity_checker': self._integrity_checker is not None,
- 'hot_reload': self.config.hot_reload_enabled and self._watcher is not None,
- }
- }
-
- def shutdown(self) -> None:
- """Shutdown the asset manager and cleanup resources."""
- pprint("Shutting down asset manager...")
-
- # Stop hot-reload watcher
- if self._watcher:
- try:
- self._watcher.stop()
- self._watcher.join(timeout=1.0)
- except Exception as e:
- pprint(f"Error stopping file watcher: {e}")
-
- # Shutdown thread pool
- if self._thread_pool:
- try:
- self._thread_pool.shutdown(wait=True, timeout=5.0)
- except Exception as e:
- pprint(f"Error shutting down thread pool: {e}")
-
- # Clear cache
- try:
- self.clear_cache()
- except Exception as e:
- pprint(f"Error clearing cache: {e}")
-
- # Cleanup components
- if self._cache:
- try:
- self._cache.clear()
- except Exception as e:
- pprint(f"Error shutting down cache: {e}")
-
- pprint("Asset manager shutdown complete")
-
- def __enter__(self):
- """Context manager entry."""
- return self
-
- def __exit__(self, exc_type, exc_val, exc_tb):
- """Context manager exit."""
- self.shutdown()
- # Convenience functions for easier usage
- def create_asset_manager(
- application_container=None,
- assets_directory: str = "./assets",
- profile: str = None,
- cache_enabled: bool = True,
- validation_enabled: bool = True,
- hot_reload_enabled: bool = False
- ) -> AssetManager:
- """
- Create an AssetManager with common configuration.
-
- Args:
- application_container: Application container for dependency injection
- assets_directory: Path to assets directory
- profile: Current asset profile
- cache_enabled: Enable asset caching
- validation_enabled: Enable asset validation
- hot_reload_enabled: Enable hot-reload for development
-
- Returns:
- Configured AssetManager instance
- """
- config = AssetManagerConfig(
- assets_directory=assets_directory,
- current_profile=profile,
- cache_enabled=cache_enabled,
- validation_enabled=validation_enabled,
- hot_reload_enabled=hot_reload_enabled
- )
-
- return AssetManager(
- application_container=application_container,
- config=config
- )
- # Module exports
- __all__ = [
- 'AssetManager',
- 'AssetManagerConfig',
- 'AssetInfo',
- 'AssetStatus',
- 'ProfileMode',
- 'AssetLoadError',
- 'AssetValidationError',
- 'AssetNotFoundError',
- 'AssetManagerError',
- 'create_asset_manager',
- 'pprint'
- ]
|