asset_manager.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976
  1. """
  2. Asset Manager for Trixy Application
  3. This module provides the main AssetManager class that implements profile-based
  4. asset loading with fallback to default assets as specified in CLAUDE.md.
  5. Features:
  6. - Profile-based asset loading: ./assets/{profile}/path → ./assets/default/path
  7. - Asset caching for improved performance
  8. - Asset validation and integrity checking
  9. - Hot-reload capability for development
  10. - Asset dependency tracking
  11. - Thread-safe operations for multi-satellite support
  12. - Integration with application container and configuration system
  13. - Comprehensive logging using pprint() function
  14. The AssetManager is designed to handle various asset types:
  15. - Audio files (success.wav, error.wav, notification sounds)
  16. - Image assets (icons, backgrounds, status indicators)
  17. - Text files (templates, messages, responses)
  18. - Configuration templates (default settings, profiles)
  19. Usage:
  20. from trixy_core.assets import AssetManager
  21. # Initialize with application container
  22. asset_manager = AssetManager(application_container)
  23. # Get asset path with profile fallback
  24. audio_path = asset_manager.get_path("audio/success.wav")
  25. # Enable hot-reload for development
  26. asset_manager.enable_hot_reload(True)
  27. # Preload assets for better performance
  28. asset_manager.preload_assets(["audio/*.wav", "images/*.png"])
  29. """
  30. import os
  31. import hashlib
  32. import threading
  33. import time
  34. import weakref
  35. from pathlib import Path
  36. from typing import Dict, Any, Optional, List, Set, Union, Callable, Tuple
  37. from dataclasses import dataclass, field
  38. from enum import Enum
  39. import json
  40. import re
  41. import mimetypes
  42. from concurrent.futures import ThreadPoolExecutor
  43. from contextlib import contextmanager
  44. from watchdog.observers import Observer
  45. from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent, FileDeletedEvent
  46. # Import required types and utilities
  47. try:
  48. from ..config import ConfigManager, DeploymentMode
  49. from ..events import EventHandler, TrixyEvent
  50. from .asset_types import AssetType, AssetMetadata, get_asset_type_from_path, validate_asset_type
  51. from .asset_cache import AssetCache, CacheEntry, CachePolicy
  52. from .asset_validator import AssetValidator, AssetValidationResult, AssetIntegrityChecker
  53. except ImportError:
  54. # Handle missing imports gracefully for initial setup
  55. ConfigManager = None
  56. EventHandler = None
  57. TrixyEvent = None
  58. AssetType = None
  59. AssetMetadata = None
  60. AssetCache = None
  61. AssetValidator = None
  62. class AssetLoadError(Exception):
  63. """Raised when asset loading fails."""
  64. pass
  65. class AssetValidationError(Exception):
  66. """Raised when asset validation fails."""
  67. pass
  68. class AssetNotFoundError(Exception):
  69. """Raised when asset is not found in any location."""
  70. pass
  71. class AssetManagerError(Exception):
  72. """Base exception for asset manager errors."""
  73. pass
  74. class AssetStatus(Enum):
  75. """Status of asset loading/validation."""
  76. UNKNOWN = "unknown"
  77. LOADING = "loading"
  78. LOADED = "loaded"
  79. CACHED = "cached"
  80. FAILED = "failed"
  81. NOT_FOUND = "not_found"
  82. VALIDATING = "validating"
  83. VALID = "valid"
  84. INVALID = "invalid"
  85. class ProfileMode(Enum):
  86. """Profile loading modes."""
  87. STRICT = "strict" # Only load from profile, no fallback
  88. FALLBACK = "fallback" # Load from profile, fallback to default
  89. DEFAULT_ONLY = "default_only" # Only load from default profile
  90. @dataclass
  91. class AssetInfo:
  92. """Information about a loaded asset."""
  93. path: str
  94. full_path: str
  95. profile: str
  96. asset_type: Optional[str] = None
  97. size: int = 0
  98. last_modified: float = 0.0
  99. checksum: Optional[str] = None
  100. status: AssetStatus = AssetStatus.UNKNOWN
  101. load_time: float = 0.0
  102. access_count: int = 0
  103. last_access: float = 0.0
  104. metadata: Dict[str, Any] = field(default_factory=dict)
  105. dependencies: Set[str] = field(default_factory=set)
  106. def __post_init__(self):
  107. """Initialize computed fields."""
  108. if self.last_access == 0.0:
  109. self.last_access = time.time()
  110. @dataclass
  111. class AssetManagerConfig:
  112. """Configuration for AssetManager."""
  113. assets_directory: str = "./assets"
  114. default_profile: str = "default"
  115. current_profile: Optional[str] = None
  116. cache_enabled: bool = True
  117. cache_max_size: int = 100 # Maximum number of cached assets
  118. cache_max_memory: int = 100 * 1024 * 1024 # 100MB cache limit
  119. validation_enabled: bool = True
  120. hot_reload_enabled: bool = False
  121. hot_reload_debounce: float = 0.5 # Debounce time for file changes
  122. preload_patterns: List[str] = field(default_factory=list)
  123. profile_mode: ProfileMode = ProfileMode.FALLBACK
  124. thread_pool_size: int = 4
  125. enable_integrity_checks: bool = True
  126. auto_create_directories: bool = True
  127. compress_cached_assets: bool = False
  128. asset_timeout: float = 30.0 # Timeout for asset loading operations
  129. class AssetWatcher(FileSystemEventHandler):
  130. """File system watcher for hot-reload functionality."""
  131. def __init__(self, asset_manager: 'AssetManager'):
  132. super().__init__()
  133. self._asset_manager_ref = weakref.ref(asset_manager)
  134. self._debounce_timers: Dict[str, threading.Timer] = {}
  135. self._lock = threading.Lock()
  136. def on_modified(self, event):
  137. """Handle file modification events."""
  138. if event.is_directory:
  139. return
  140. self._handle_file_change(event.src_path, "modified")
  141. def on_created(self, event):
  142. """Handle file creation events."""
  143. if event.is_directory:
  144. return
  145. self._handle_file_change(event.src_path, "created")
  146. def on_deleted(self, event):
  147. """Handle file deletion events."""
  148. if event.is_directory:
  149. return
  150. self._handle_file_change(event.src_path, "deleted")
  151. def _handle_file_change(self, file_path: str, change_type: str):
  152. """Handle file system changes with debouncing."""
  153. asset_manager = self._asset_manager_ref()
  154. if asset_manager is None:
  155. return
  156. with self._lock:
  157. # Cancel existing timer for this file
  158. if file_path in self._debounce_timers:
  159. self._debounce_timers[file_path].cancel()
  160. # Create new debounced timer
  161. timer = threading.Timer(
  162. asset_manager.config.hot_reload_debounce,
  163. lambda: asset_manager._handle_asset_change(file_path, change_type)
  164. )
  165. self._debounce_timers[file_path] = timer
  166. timer.start()
  167. def pprint(message: str) -> None:
  168. """
  169. Asset manager logging function that adapts based on mode.
  170. Uses the same pattern as specified in CLAUDE.md.
  171. """
  172. print(f"[ASSET_MANAGER] {message}")
  173. class AssetManager:
  174. """
  175. Professional asset management system for Trixy application.
  176. Provides profile-based asset loading with fallback to default assets,
  177. comprehensive caching, validation, and hot-reload capabilities.
  178. Key features:
  179. - Profile-based loading: ./assets/{profile}/path → ./assets/default/path
  180. - Asset caching with configurable policies
  181. - Asset validation and integrity checking
  182. - Hot-reload for development
  183. - Thread-safe operations
  184. - Asset dependency tracking
  185. - Integration with application container
  186. """
  187. def __init__(
  188. self,
  189. application_container=None,
  190. config: Optional[AssetManagerConfig] = None,
  191. assets_directory: Optional[str] = None,
  192. profile: Optional[str] = None
  193. ):
  194. """
  195. Initialize the AssetManager.
  196. Args:
  197. application_container: Application container for dependency injection
  198. config: Asset manager configuration
  199. assets_directory: Override assets directory path
  200. profile: Override current profile
  201. """
  202. # Store application container reference
  203. self.application = application_container
  204. self.application_ref = weakref.ref(application_container) if application_container else None
  205. # Initialize configuration
  206. self.config = config or AssetManagerConfig()
  207. if assets_directory:
  208. self.config.assets_directory = assets_directory
  209. if profile:
  210. self.config.current_profile = profile
  211. # Initialize state
  212. self._assets: Dict[str, AssetInfo] = {}
  213. self._assets_lock = threading.RLock()
  214. self._startup_time = time.time()
  215. self._stats = {
  216. 'total_loads': 0,
  217. 'cache_hits': 0,
  218. 'cache_misses': 0,
  219. 'validation_failures': 0,
  220. 'hot_reload_events': 0
  221. }
  222. # Initialize components
  223. self._cache: Optional[AssetCache] = None
  224. self._validator: Optional[AssetValidator] = None
  225. self._integrity_checker: Optional[AssetIntegrityChecker] = None
  226. self._thread_pool: Optional[ThreadPoolExecutor] = None
  227. self._watcher: Optional[Observer] = None
  228. self._asset_watcher: Optional[AssetWatcher] = None
  229. # Initialize logging
  230. pprint(f"AssetManager initialized with profile: {self.config.current_profile or 'default'}")
  231. # Initialize components and validate setup
  232. self._initialize_components()
  233. self._validate_setup()
  234. # Preload assets if configured
  235. if self.config.preload_patterns:
  236. self._preload_assets()
  237. def _initialize_components(self) -> None:
  238. """Initialize asset manager components."""
  239. try:
  240. # Initialize thread pool
  241. self._thread_pool = ThreadPoolExecutor(
  242. max_workers=self.config.thread_pool_size,
  243. thread_name_prefix="asset_manager"
  244. )
  245. # Initialize cache if enabled
  246. if self.config.cache_enabled and AssetCache:
  247. cache_policy = CachePolicy(
  248. max_entries=self.config.cache_max_size,
  249. max_memory_bytes=self.config.cache_max_memory,
  250. compress=self.config.compress_cached_assets
  251. )
  252. self._cache = AssetCache(policy=cache_policy)
  253. pprint("Asset cache initialized")
  254. # Initialize validator if enabled and available
  255. if self.config.validation_enabled and AssetValidator:
  256. self._validator = AssetValidator()
  257. pprint("Asset validator initialized")
  258. # Initialize integrity checker if enabled and available
  259. if self.config.enable_integrity_checks and AssetIntegrityChecker:
  260. self._integrity_checker = AssetIntegrityChecker()
  261. pprint("Asset integrity checker initialized")
  262. # Initialize hot-reload watcher if enabled
  263. if self.config.hot_reload_enabled:
  264. self._setup_hot_reload()
  265. pprint("Asset manager components initialized")
  266. except Exception as e:
  267. pprint(f"Error initializing asset manager components: {e}")
  268. # Continue with limited functionality
  269. def _validate_setup(self) -> None:
  270. """Validate asset manager setup and configuration."""
  271. assets_path = Path(self.config.assets_directory)
  272. default_path = assets_path / self.config.default_profile
  273. # Create directories if needed and allowed
  274. if self.config.auto_create_directories:
  275. try:
  276. default_path.mkdir(parents=True, exist_ok=True)
  277. (default_path / "audio").mkdir(exist_ok=True)
  278. (default_path / "images").mkdir(exist_ok=True)
  279. (default_path / "text").mkdir(exist_ok=True)
  280. (default_path / "config").mkdir(exist_ok=True)
  281. pprint(f"Created default asset directories at: {default_path}")
  282. except Exception as e:
  283. pprint(f"Warning: Could not create asset directories: {e}")
  284. # Validate assets directory exists
  285. if not assets_path.exists():
  286. pprint(f"Warning: Assets directory does not exist: {assets_path}")
  287. elif not default_path.exists():
  288. pprint(f"Warning: Default profile directory does not exist: {default_path}")
  289. # Log current configuration
  290. pprint(f"Assets directory: {self.config.assets_directory}")
  291. pprint(f"Default profile: {self.config.default_profile}")
  292. pprint(f"Current profile: {self.config.current_profile or 'none (using default)'}")
  293. pprint(f"Profile mode: {self.config.profile_mode.value}")
  294. pprint(f"Cache enabled: {self.config.cache_enabled}")
  295. pprint(f"Validation enabled: {self.config.validation_enabled}")
  296. pprint(f"Hot-reload enabled: {self.config.hot_reload_enabled}")
  297. def _setup_hot_reload(self) -> None:
  298. """Setup hot-reload file system watcher."""
  299. try:
  300. from watchdog.observers import Observer
  301. self._asset_watcher = AssetWatcher(self)
  302. self._watcher = Observer()
  303. # Watch assets directory
  304. assets_path = Path(self.config.assets_directory)
  305. if assets_path.exists():
  306. self._watcher.schedule(self._asset_watcher, str(assets_path), recursive=True)
  307. self._watcher.start()
  308. pprint(f"Hot-reload watcher started for: {assets_path}")
  309. else:
  310. pprint(f"Warning: Cannot start hot-reload watcher, assets directory not found: {assets_path}")
  311. except ImportError:
  312. pprint("Warning: Hot-reload requires watchdog package, but it's not available")
  313. except Exception as e:
  314. pprint(f"Warning: Failed to setup hot-reload: {e}")
  315. def _preload_assets(self) -> None:
  316. """Preload assets based on configured patterns."""
  317. if not self.config.preload_patterns:
  318. return
  319. pprint(f"Preloading assets with patterns: {self.config.preload_patterns}")
  320. preload_count = 0
  321. for pattern in self.config.preload_patterns:
  322. try:
  323. # Find matching assets
  324. assets_to_preload = self._find_assets_by_pattern(pattern)
  325. # Preload each asset
  326. for asset_path in assets_to_preload:
  327. try:
  328. self.get_path(asset_path)
  329. preload_count += 1
  330. except Exception as e:
  331. pprint(f"Failed to preload asset {asset_path}: {e}")
  332. except Exception as e:
  333. pprint(f"Error processing preload pattern {pattern}: {e}")
  334. pprint(f"Preloaded {preload_count} assets")
  335. def _find_assets_by_pattern(self, pattern: str) -> List[str]:
  336. """Find assets matching a glob pattern."""
  337. import glob
  338. assets_found = []
  339. base_path = Path(self.config.assets_directory)
  340. # Check both current profile and default profile
  341. profiles_to_check = [self.config.default_profile]
  342. if self.config.current_profile and self.config.current_profile != self.config.default_profile:
  343. profiles_to_check.insert(0, self.config.current_profile)
  344. for profile in profiles_to_check:
  345. profile_path = base_path / profile
  346. if profile_path.exists():
  347. # Create full pattern path
  348. full_pattern = str(profile_path / pattern)
  349. # Find matching files
  350. matches = glob.glob(full_pattern, recursive=True)
  351. # Convert to relative asset paths
  352. for match in matches:
  353. try:
  354. # Convert absolute path to relative asset path
  355. relative_path = Path(match).relative_to(profile_path)
  356. asset_path = str(relative_path).replace(os.sep, '/')
  357. # Avoid duplicates
  358. if asset_path not in assets_found:
  359. assets_found.append(asset_path)
  360. except ValueError:
  361. # Path is not relative to profile path
  362. continue
  363. return assets_found
  364. def get_path(self, asset_path: str) -> Union[str, bool]:
  365. """
  366. Get the full file system path for an asset with profile-based fallback.
  367. This is the main method specified in CLAUDE.md for asset resolution:
  368. - Primary: ./assets/{profile}/path/to/asset
  369. - Fallback: ./assets/default/path/to/asset
  370. - Returns False if asset not found in either location
  371. Args:
  372. asset_path: Relative path to the asset (e.g., "audio/success.wav")
  373. Returns:
  374. str: Full path to the asset file if found
  375. bool: False if asset not found in any location
  376. """
  377. if not asset_path:
  378. return False
  379. # Normalize path separators
  380. asset_path = asset_path.replace('\\', '/')
  381. # Check cache first
  382. with self._assets_lock:
  383. if asset_path in self._assets:
  384. asset_info = self._assets[asset_path]
  385. asset_info.access_count += 1
  386. asset_info.last_access = time.time()
  387. # Verify file still exists
  388. if os.path.exists(asset_info.full_path):
  389. self._stats['cache_hits'] += 1
  390. return asset_info.full_path
  391. else:
  392. # File was deleted, remove from cache
  393. del self._assets[asset_path]
  394. pprint(f"Asset removed from cache (file deleted): {asset_path}")
  395. # Asset not in cache or cache miss
  396. self._stats['cache_misses'] += 1
  397. self._stats['total_loads'] += 1
  398. # Try to load asset
  399. try:
  400. full_path = self._resolve_asset_path(asset_path)
  401. if full_path:
  402. # Create asset info and add to cache
  403. asset_info = self._create_asset_info(asset_path, full_path)
  404. # Validate asset if enabled
  405. if self._should_validate_asset(asset_info):
  406. self._validate_asset(asset_info)
  407. # Store in cache
  408. with self._assets_lock:
  409. self._assets[asset_path] = asset_info
  410. return full_path
  411. else:
  412. return False
  413. except Exception as e:
  414. pprint(f"Error loading asset {asset_path}: {e}")
  415. return False
  416. def _resolve_asset_path(self, asset_path: str) -> Optional[str]:
  417. """
  418. Resolve asset path using profile-based fallback logic.
  419. Args:
  420. asset_path: Relative asset path
  421. Returns:
  422. Full file system path if found, None otherwise
  423. """
  424. base_path = Path(self.config.assets_directory)
  425. # Determine which profiles to check based on mode
  426. if self.config.profile_mode == ProfileMode.DEFAULT_ONLY:
  427. profiles_to_check = [self.config.default_profile]
  428. elif self.config.profile_mode == ProfileMode.STRICT:
  429. if self.config.current_profile:
  430. profiles_to_check = [self.config.current_profile]
  431. else:
  432. profiles_to_check = [self.config.default_profile]
  433. else: # FALLBACK mode (default)
  434. profiles_to_check = [self.config.default_profile]
  435. if self.config.current_profile and self.config.current_profile != self.config.default_profile:
  436. profiles_to_check.insert(0, self.config.current_profile)
  437. # Try each profile in order
  438. for profile in profiles_to_check:
  439. profile_path = base_path / profile / asset_path
  440. if profile_path.exists() and profile_path.is_file():
  441. return str(profile_path.resolve())
  442. return None
  443. def _create_asset_info(self, asset_path: str, full_path: str) -> AssetInfo:
  444. """Create AssetInfo object for a loaded asset."""
  445. path_obj = Path(full_path)
  446. # Determine profile from full path
  447. base_path = Path(self.config.assets_directory)
  448. try:
  449. relative_to_assets = path_obj.relative_to(base_path)
  450. profile = relative_to_assets.parts[0] if relative_to_assets.parts else self.config.default_profile
  451. except ValueError:
  452. profile = "unknown"
  453. # Get file information
  454. stat = path_obj.stat()
  455. # Create asset info
  456. asset_info = AssetInfo(
  457. path=asset_path,
  458. full_path=full_path,
  459. profile=profile,
  460. size=stat.st_size,
  461. last_modified=stat.st_mtime,
  462. status=AssetStatus.LOADED,
  463. load_time=time.time()
  464. )
  465. # Determine asset type if available
  466. if AssetType:
  467. try:
  468. asset_info.asset_type = get_asset_type_from_path(asset_path)
  469. except Exception:
  470. asset_info.asset_type = None
  471. # Calculate checksum if integrity checking is enabled
  472. if self.config.enable_integrity_checks:
  473. try:
  474. asset_info.checksum = self._calculate_checksum(full_path)
  475. except Exception as e:
  476. pprint(f"Warning: Could not calculate checksum for {asset_path}: {e}")
  477. return asset_info
  478. def _should_validate_asset(self, asset_info: AssetInfo) -> bool:
  479. """Determine if an asset should be validated."""
  480. return (
  481. self.config.validation_enabled and
  482. self._validator is not None and
  483. asset_info.asset_type is not None
  484. )
  485. def _validate_asset(self, asset_info: AssetInfo) -> None:
  486. """Validate an asset and update its status."""
  487. if not self._validator:
  488. return
  489. try:
  490. asset_info.status = AssetStatus.VALIDATING
  491. result = self._validator.validate_asset(
  492. asset_info.full_path,
  493. asset_info.asset_type
  494. )
  495. if result and result.is_valid:
  496. asset_info.status = AssetStatus.VALID
  497. if result.metadata:
  498. asset_info.metadata.update(result.metadata)
  499. else:
  500. asset_info.status = AssetStatus.INVALID
  501. self._stats['validation_failures'] += 1
  502. error_msg = f"Asset validation failed: {asset_info.path}"
  503. if result and result.errors:
  504. error_msg += f" - {', '.join(result.errors)}"
  505. pprint(error_msg)
  506. except Exception as e:
  507. asset_info.status = AssetStatus.INVALID
  508. self._stats['validation_failures'] += 1
  509. pprint(f"Asset validation error for {asset_info.path}: {e}")
  510. def _calculate_checksum(self, file_path: str) -> str:
  511. """Calculate SHA256 checksum for a file."""
  512. hash_sha256 = hashlib.sha256()
  513. with open(file_path, 'rb') as f:
  514. for chunk in iter(lambda: f.read(4096), b""):
  515. hash_sha256.update(chunk)
  516. return hash_sha256.hexdigest()
  517. def _handle_asset_change(self, file_path: str, change_type: str) -> None:
  518. """Handle asset file system changes for hot-reload."""
  519. if not self.config.hot_reload_enabled:
  520. return
  521. self._stats['hot_reload_events'] += 1
  522. try:
  523. # Convert file path to asset path
  524. base_path = Path(self.config.assets_directory)
  525. file_path_obj = Path(file_path)
  526. # Check if file is within assets directory
  527. try:
  528. relative_path = file_path_obj.relative_to(base_path)
  529. # Extract profile and asset path
  530. if len(relative_path.parts) < 2:
  531. return # Not a valid asset path
  532. profile = relative_path.parts[0]
  533. asset_path = '/'.join(relative_path.parts[1:])
  534. pprint(f"Asset {change_type}: {asset_path} (profile: {profile})")
  535. # Handle different change types
  536. if change_type == "deleted":
  537. self._handle_asset_deleted(asset_path)
  538. elif change_type in ["modified", "created"]:
  539. self._handle_asset_updated(asset_path)
  540. except ValueError:
  541. # File is not within assets directory
  542. return
  543. except Exception as e:
  544. pprint(f"Error handling asset change {file_path}: {e}")
  545. def _handle_asset_deleted(self, asset_path: str) -> None:
  546. """Handle asset deletion."""
  547. with self._assets_lock:
  548. if asset_path in self._assets:
  549. del self._assets[asset_path]
  550. pprint(f"Removed deleted asset from cache: {asset_path}")
  551. # Remove from cache if available
  552. if self._cache:
  553. self._cache.remove(asset_path)
  554. def _handle_asset_updated(self, asset_path: str) -> None:
  555. """Handle asset update/creation."""
  556. with self._assets_lock:
  557. if asset_path in self._assets:
  558. # Remove from cache to force reload
  559. del self._assets[asset_path]
  560. pprint(f"Invalidated cache for updated asset: {asset_path}")
  561. # Remove from cache if available
  562. if self._cache:
  563. self._cache.remove(asset_path)
  564. # Preload asset if it matches preload patterns
  565. for pattern in self.config.preload_patterns:
  566. if self._matches_pattern(asset_path, pattern):
  567. try:
  568. self.get_path(asset_path)
  569. pprint(f"Preloaded updated asset: {asset_path}")
  570. except Exception as e:
  571. pprint(f"Failed to preload updated asset {asset_path}: {e}")
  572. break
  573. def _matches_pattern(self, asset_path: str, pattern: str) -> bool:
  574. """Check if asset path matches a glob pattern."""
  575. import fnmatch
  576. return fnmatch.fnmatch(asset_path, pattern)
  577. def get_asset_info(self, asset_path: str) -> Optional[AssetInfo]:
  578. """
  579. Get detailed information about an asset.
  580. Args:
  581. asset_path: Relative path to the asset
  582. Returns:
  583. AssetInfo object if asset is loaded, None otherwise
  584. """
  585. with self._assets_lock:
  586. return self._assets.get(asset_path)
  587. def list_assets(self, profile: Optional[str] = None) -> List[str]:
  588. """
  589. List all available assets in a profile.
  590. Args:
  591. profile: Profile to list assets from (None for current profile)
  592. Returns:
  593. List of asset paths
  594. """
  595. if profile is None:
  596. profile = self.config.current_profile or self.config.default_profile
  597. assets = []
  598. profile_path = Path(self.config.assets_directory) / profile
  599. if profile_path.exists():
  600. for root, dirs, files in os.walk(profile_path):
  601. for file in files:
  602. file_path = Path(root) / file
  603. try:
  604. relative_path = file_path.relative_to(profile_path)
  605. asset_path = str(relative_path).replace(os.sep, '/')
  606. assets.append(asset_path)
  607. except ValueError:
  608. continue
  609. return sorted(assets)
  610. def clear_cache(self) -> None:
  611. """Clear the asset cache."""
  612. with self._assets_lock:
  613. cache_size = len(self._assets)
  614. self._assets.clear()
  615. pprint(f"Cleared asset cache ({cache_size} entries)")
  616. if self._cache:
  617. self._cache.clear()
  618. def preload_assets(self, patterns: List[str]) -> int:
  619. """
  620. Preload assets matching the given patterns.
  621. Args:
  622. patterns: List of glob patterns to match
  623. Returns:
  624. Number of assets preloaded
  625. """
  626. preload_count = 0
  627. for pattern in patterns:
  628. assets_to_preload = self._find_assets_by_pattern(pattern)
  629. for asset_path in assets_to_preload:
  630. try:
  631. result = self.get_path(asset_path)
  632. if result:
  633. preload_count += 1
  634. except Exception as e:
  635. pprint(f"Failed to preload asset {asset_path}: {e}")
  636. pprint(f"Preloaded {preload_count} assets")
  637. return preload_count
  638. def get_profile(self) -> str:
  639. """Get the current asset profile."""
  640. return self.config.current_profile or self.config.default_profile
  641. def set_profile(self, profile: str) -> None:
  642. """
  643. Set the current asset profile.
  644. Args:
  645. profile: Profile name to set as current
  646. """
  647. if profile == self.config.current_profile:
  648. return
  649. old_profile = self.config.current_profile
  650. self.config.current_profile = profile
  651. pprint(f"Asset profile changed: {old_profile} → {profile}")
  652. # Clear cache to force reload with new profile
  653. self.clear_cache()
  654. def enable_hot_reload(self, enabled: bool = True) -> None:
  655. """
  656. Enable or disable hot-reload functionality.
  657. Args:
  658. enabled: Whether to enable hot-reload
  659. """
  660. if enabled == self.config.hot_reload_enabled:
  661. return
  662. self.config.hot_reload_enabled = enabled
  663. if enabled:
  664. self._setup_hot_reload()
  665. pprint("Hot-reload enabled")
  666. else:
  667. if self._watcher:
  668. self._watcher.stop()
  669. self._watcher = None
  670. self._asset_watcher = None
  671. pprint("Hot-reload disabled")
  672. def get_stats(self) -> Dict[str, Any]:
  673. """
  674. Get asset manager statistics.
  675. Returns:
  676. Dictionary containing statistics
  677. """
  678. with self._assets_lock:
  679. cache_stats = {
  680. 'cached_assets': len(self._assets),
  681. 'cache_memory_usage': sum(info.size for info in self._assets.values()),
  682. 'total_access_count': sum(info.access_count for info in self._assets.values()),
  683. }
  684. return {
  685. **self._stats,
  686. **cache_stats,
  687. 'uptime_seconds': time.time() - self._startup_time,
  688. 'current_profile': self.get_profile(),
  689. 'cache_enabled': self.config.cache_enabled,
  690. 'validation_enabled': self.config.validation_enabled,
  691. 'hot_reload_enabled': self.config.hot_reload_enabled,
  692. }
  693. def get_health_status(self) -> Dict[str, Any]:
  694. """
  695. Get health status of the asset manager.
  696. Returns:
  697. Dictionary containing health information
  698. """
  699. stats = self.get_stats()
  700. return {
  701. 'status': 'healthy', # Could be enhanced with actual health checks
  702. 'assets_directory_exists': Path(self.config.assets_directory).exists(),
  703. 'default_profile_exists': (Path(self.config.assets_directory) / self.config.default_profile).exists(),
  704. 'current_profile_exists': (Path(self.config.assets_directory) / self.get_profile()).exists(),
  705. 'cache_hit_rate': stats['cache_hits'] / max(1, stats['cache_hits'] + stats['cache_misses']),
  706. 'validation_failure_rate': stats['validation_failures'] / max(1, stats['total_loads']),
  707. 'components': {
  708. 'cache': self._cache is not None,
  709. 'validator': self._validator is not None,
  710. 'integrity_checker': self._integrity_checker is not None,
  711. 'hot_reload': self.config.hot_reload_enabled and self._watcher is not None,
  712. }
  713. }
  714. def shutdown(self) -> None:
  715. """Shutdown the asset manager and cleanup resources."""
  716. pprint("Shutting down asset manager...")
  717. # Stop hot-reload watcher
  718. if self._watcher:
  719. try:
  720. self._watcher.stop()
  721. self._watcher.join(timeout=1.0)
  722. except Exception as e:
  723. pprint(f"Error stopping file watcher: {e}")
  724. # Shutdown thread pool
  725. if self._thread_pool:
  726. try:
  727. self._thread_pool.shutdown(wait=True, timeout=5.0)
  728. except Exception as e:
  729. pprint(f"Error shutting down thread pool: {e}")
  730. # Clear cache
  731. try:
  732. self.clear_cache()
  733. except Exception as e:
  734. pprint(f"Error clearing cache: {e}")
  735. # Cleanup components
  736. if self._cache:
  737. try:
  738. self._cache.clear()
  739. except Exception as e:
  740. pprint(f"Error shutting down cache: {e}")
  741. pprint("Asset manager shutdown complete")
  742. def __enter__(self):
  743. """Context manager entry."""
  744. return self
  745. def __exit__(self, exc_type, exc_val, exc_tb):
  746. """Context manager exit."""
  747. self.shutdown()
  748. # Convenience functions for easier usage
  749. def create_asset_manager(
  750. application_container=None,
  751. assets_directory: str = "./assets",
  752. profile: str = None,
  753. cache_enabled: bool = True,
  754. validation_enabled: bool = True,
  755. hot_reload_enabled: bool = False
  756. ) -> AssetManager:
  757. """
  758. Create an AssetManager with common configuration.
  759. Args:
  760. application_container: Application container for dependency injection
  761. assets_directory: Path to assets directory
  762. profile: Current asset profile
  763. cache_enabled: Enable asset caching
  764. validation_enabled: Enable asset validation
  765. hot_reload_enabled: Enable hot-reload for development
  766. Returns:
  767. Configured AssetManager instance
  768. """
  769. config = AssetManagerConfig(
  770. assets_directory=assets_directory,
  771. current_profile=profile,
  772. cache_enabled=cache_enabled,
  773. validation_enabled=validation_enabled,
  774. hot_reload_enabled=hot_reload_enabled
  775. )
  776. return AssetManager(
  777. application_container=application_container,
  778. config=config
  779. )
  780. # Module exports
  781. __all__ = [
  782. 'AssetManager',
  783. 'AssetManagerConfig',
  784. 'AssetInfo',
  785. 'AssetStatus',
  786. 'ProfileMode',
  787. 'AssetLoadError',
  788. 'AssetValidationError',
  789. 'AssetNotFoundError',
  790. 'AssetManagerError',
  791. 'create_asset_manager',
  792. 'pprint'
  793. ]