Coverage for src / moai_adk / statusline / version_reader.py: 25.68%

296 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1""" 

2Enhanced Version reader for MoAI-ADK from config.json with performance optimizations 

3 

4Refactored for improved performance, error handling, configurability, and caching strategies 

5""" 

6 

7import asyncio 

8import json 

9import logging 

10import os 

11import re 

12import time 

13from dataclasses import dataclass, field 

14from datetime import datetime, timedelta 

15from enum import Enum 

16from pathlib import Path 

17from typing import Any, Dict, List, Optional 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22class VersionSource(Enum): 

23 """Enum for version source tracking""" 

24 

25 CONFIG_FILE = "config_file" 

26 FALLBACK = "fallback" 

27 PACKAGE = "package" 

28 CACHE = "cache" 

29 

30 

31@dataclass 

32class CacheEntry: 

33 """Cache entry with metadata""" 

34 

35 version: str 

36 timestamp: datetime 

37 source: VersionSource 

38 access_count: int = 0 

39 last_access: datetime = field(default_factory=datetime.now) 

40 

41 

42@dataclass 

43class VersionConfig: 

44 """Configuration for version reading behavior with enhanced options""" 

45 

46 # Cache configuration 

47 cache_ttl_seconds: int = 60 

48 cache_enabled: bool = True 

49 cache_size: int = 50 # Maximum number of cached entries 

50 enable_lru_cache: bool = True # Enable least recently used cache eviction 

51 

52 # Fallback configuration 

53 fallback_version: str = "unknown" 

54 fallback_source: VersionSource = VersionSource.FALLBACK 

55 

56 # Validation configuration 

57 version_format_regex: str = r"^v?(\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?)$" 

58 enable_validation: bool = True 

59 strict_validation: bool = False 

60 

61 # Performance configuration 

62 enable_async: bool = True 

63 enable_batch_reading: bool = True 

64 batch_size: int = 10 

65 timeout_seconds: int = 5 

66 

67 # Debug configuration 

68 debug_mode: bool = False 

69 enable_detailed_logging: bool = False 

70 track_performance_metrics: bool = True 

71 

72 # Version field priority configuration 

73 # Order: 1) MoAI package version, 2) Project version, 3) Fallbacks 

74 version_fields: List[str] = field( 

75 default_factory=lambda: [ 

76 "moai.version", # ← 1st priority: MoAI-ADK version 

77 "project.version", # ← 2nd priority: Project version 

78 "version", # ← 3rd priority: General version 

79 "project.template_version", # ← 4th priority: Template version 

80 "template_version", 

81 ] 

82 ) 

83 

84 

85class VersionReader: 

86 """ 

87 Enhanced version reader for MoAI-ADK with advanced caching, 

88 performance optimization, and comprehensive error handling. 

89 

90 Features: 

91 - Multi-level caching with LRU eviction strategy 

92 - Configurable version field priority 

93 - Async batch processing for better performance 

94 - Comprehensive error handling and recovery 

95 - Version format validation with customizable patterns 

96 - Performance metrics tracking 

97 - Graceful degradation strategies 

98 - Source tracking for debugging 

99 """ 

100 

101 # Default configuration 

102 DEFAULT_CONFIG = VersionConfig() 

103 

104 # Supported version fields in order of priority 

105 # Order: 1) MoAI package version, 2) Project version, 3) Fallbacks 

106 DEFAULT_VERSION_FIELDS = [ 

107 "moai.version", # ← 1st priority: MoAI-ADK version 

108 "project.version", # ← 2nd priority: Project version 

109 "version", # ← 3rd priority: General version 

110 "project.template_version", # ← 4th priority: Template version 

111 "template_version", 

112 ] 

113 

114 def __init__(self, config: Optional[VersionConfig] = None, working_dir: Optional[Path] = None): 

115 """ 

116 Initialize version reader with enhanced configuration. 

117 

118 Args: 

119 config: Version configuration object. If None, uses defaults. 

120 working_dir: Working directory to search for config. If None, uses environment detection. 

121 """ 

122 self.config = config or self.DEFAULT_CONFIG 

123 

124 # Determine working directory with priority: 

125 # 1. Explicit working_dir parameter 

126 # 2. CLAUDE_PROJECT_DIR environment variable (set by Claude Code) 

127 # 3. Current working directory 

128 if working_dir: 

129 base_dir = Path(working_dir) 

130 elif "CLAUDE_PROJECT_DIR" in os.environ: 

131 base_dir = Path(os.environ["CLAUDE_PROJECT_DIR"]) 

132 else: 

133 base_dir = Path.cwd() 

134 

135 self._config_path = base_dir / ".moai" / "config" / "config.json" 

136 

137 # Enhanced caching with LRU support 

138 self._cache: Dict[str, CacheEntry] = {} 

139 self._cache_stats = { 

140 "hits": 0, 

141 "misses": 0, 

142 "errors": 0, 

143 "cache_hits_by_source": { 

144 VersionSource.CONFIG_FILE.value: 0, 

145 VersionSource.CACHE.value: 0, 

146 VersionSource.FALLBACK.value: 0, 

147 }, 

148 } 

149 

150 # Performance tracking 

151 self._performance_metrics = { 

152 "read_times": [], 

153 "validation_times": [], 

154 "cache_operation_times": [], 

155 } 

156 

157 # Version field configuration (backwards compatibility) 

158 self._version_fields = self.config.version_fields.copy() 

159 self.VERSION_FIELDS = self._version_fields.copy() 

160 

161 # Pre-compile regex for performance 

162 try: 

163 self._version_pattern = re.compile(self.config.version_format_regex) 

164 except re.error: 

165 self._version_pattern = re.compile(self.DEFAULT_CONFIG.version_format_regex) 

166 

167 # Logging 

168 self._logger = logging.getLogger(__name__) 

169 

170 # Backwards compatibility cache attributes 

171 self._version_cache: Optional[str] = None 

172 self._cache_time: Optional[datetime] = None 

173 self._cache_ttl = timedelta(seconds=self.config.cache_ttl_seconds) 

174 

175 if self.config.debug_mode: 

176 self._logger.info(f"VersionReader initialized with config: {self.config}") 

177 

178 def get_version(self) -> str: 

179 """ 

180 Get MoAI-ADK version from config with enhanced caching. 

181 

182 Returns: 

183 Version string (e.g., "0.20.1" or "v0.20.1") 

184 

185 Raises: 

186 VersionReadError: If version cannot be determined after fallbacks 

187 """ 

188 if self.config.enable_async: 

189 return asyncio.run(self.get_version_async()) 

190 else: 

191 return self.get_version_sync() 

192 

193 def get_version_sync(self) -> str: 

194 """ 

195 Synchronous version getter for performance-critical paths. 

196 

197 Returns: 

198 Version string 

199 """ 

200 start_time = time.time() 

201 

202 try: 

203 # Check cache first 

204 version = self._check_cache() 

205 if version is not None: 

206 self._cache_stats["hits"] += 1 

207 self._cache_stats["cache_hits_by_source"][ 

208 VersionSource.CACHE.value 

209 ] += 1 

210 return version 

211 

212 # Read from config file 

213 version = self._read_version_from_config_sync() 

214 if not version: 

215 version = self._get_fallback_version() 

216 self._update_cache(version, VersionSource.CONFIG_FILE) 

217 self._cache_stats["misses"] += 1 

218 return version 

219 

220 except Exception as e: 

221 self._handle_read_error(e, start_time) 

222 return self._get_fallback_version() 

223 

224 finally: 

225 self._log_performance(start_time, "sync_read") 

226 

227 async def get_version_async(self) -> str: 

228 """ 

229 Async version getter for better performance. 

230 

231 Returns: 

232 Version string 

233 """ 

234 start_time = time.time() 

235 

236 try: 

237 # Check cache first 

238 version = self._check_cache() 

239 if version is not None: 

240 self._cache_stats["hits"] += 1 

241 self._cache_stats["cache_hits_by_source"][ 

242 VersionSource.CACHE.value 

243 ] += 1 

244 return version 

245 

246 # Read from config file asynchronously 

247 version = await self._read_version_from_config_async() 

248 if not version: 

249 version = self._get_fallback_version() 

250 self._update_cache(version, VersionSource.CONFIG_FILE) 

251 self._cache_stats["misses"] += 1 

252 return version 

253 

254 except Exception as e: 

255 self._handle_read_error(e, start_time) 

256 return self._get_fallback_version() 

257 

258 finally: 

259 self._log_performance(start_time, "async_read") 

260 

261 # Enhanced internal methods 

262 def _check_cache(self) -> Optional[str]: 

263 """ 

264 Check cache for valid version entry. 

265 

266 Returns: 

267 Version string if cache is valid, None otherwise 

268 """ 

269 if not self.config.cache_enabled: 

270 return None 

271 

272 # Check for existing cache entries 

273 config_key = str(self._config_path) 

274 if config_key in self._cache: 

275 entry = self._cache[config_key] 

276 

277 # Check if cache entry is still valid 

278 if self._is_cache_entry_valid(entry): 

279 entry.access_count += 1 

280 entry.last_access = datetime.now() 

281 self._cache_stats["hits"] += 1 

282 self._cache_stats["cache_hits_by_source"][ 

283 VersionSource.CACHE.value 

284 ] += 1 

285 

286 if self.config.debug_mode: 

287 self._logger.debug( 

288 f"Cache hit: {entry.version} (source: {entry.source.value})" 

289 ) 

290 

291 return entry.version 

292 

293 return None 

294 

295 def _is_cache_entry_valid(self, entry: CacheEntry) -> bool: 

296 """ 

297 Check if cache entry is still valid. 

298 

299 Args: 

300 entry: Cache entry to validate 

301 

302 Returns: 

303 True if cache entry is valid 

304 """ 

305 # Check TTL 

306 if self.config.cache_enabled: 

307 age = datetime.now() - entry.timestamp 

308 if age.total_seconds() > self.config.cache_ttl_seconds: 

309 return False 

310 

311 return True 

312 

313 def _update_cache(self, version: str, source: VersionSource) -> None: 

314 """ 

315 Update cache with new version entry. 

316 

317 Args: 

318 version: Version string to cache 

319 source: Source of the version 

320 """ 

321 if not self.config.cache_enabled: 

322 return 

323 

324 config_key = str(self._config_path) 

325 entry = CacheEntry(version=version, timestamp=datetime.now(), source=source) 

326 

327 self._cache[config_key] = entry 

328 

329 # Apply cache size limits with LRU eviction 

330 if len(self._cache) > self.config.cache_size: 

331 self._evict_oldest_cache_entry() 

332 

333 if self.config.debug_mode: 

334 self._logger.debug( 

335 f"Cache updated with version: {version} (source: {source.value})" 

336 ) 

337 

338 def _evict_oldest_cache_entry(self) -> None: 

339 """ 

340 Evict the least recently used cache entry. 

341 """ 

342 if not self.config.enable_lru_cache or len(self._cache) <= 1: 

343 return 

344 

345 oldest_entry = None 

346 oldest_key = None 

347 

348 for key, entry in self._cache.items(): 

349 if oldest_entry is None or entry.last_access < oldest_entry.last_access: 

350 oldest_entry = entry 

351 oldest_key = key 

352 

353 if oldest_key is not None: 

354 del self._cache[oldest_key] 

355 if self.config.debug_mode: 

356 self._logger.debug(f"Evicted oldest cache entry: {oldest_key}") 

357 

358 def _handle_read_error(self, error: Exception, start_time: float) -> None: 

359 """ 

360 Handle read errors with enhanced logging and recovery. 

361 

362 Args: 

363 error: Exception that occurred 

364 start_time: When the operation started 

365 """ 

366 self._cache_stats["errors"] += 1 

367 

368 error_msg = f"Error reading version: {error}" 

369 if self.config.debug_mode: 

370 self._logger.error(error_msg, exc_info=True) 

371 else: 

372 self._logger.warning(error_msg) 

373 

374 self._log_performance(start_time, "error_read") 

375 

376 def _log_performance(self, start_time: float, operation: str) -> None: 

377 """ 

378 Log performance metrics for the operation. 

379 

380 Args: 

381 start_time: When the operation started 

382 operation: Type of operation being logged 

383 """ 

384 if not self.config.track_performance_metrics: 

385 return 

386 

387 duration = time.time() - start_time 

388 metric_name = f"{operation}_duration" 

389 

390 if metric_name not in self._performance_metrics: 

391 self._performance_metrics[metric_name] = [] 

392 

393 self._performance_metrics[metric_name].append(duration) 

394 

395 if self.config.debug_mode: 

396 self._logger.debug(f"Performance {operation}: {duration:.4f}s") 

397 

398 def get_performance_metrics(self) -> Dict[str, Any]: 

399 """ 

400 Get performance metrics for analysis. 

401 

402 Returns: 

403 Dictionary containing performance metrics 

404 """ 

405 metrics = { 

406 "cache_stats": self._cache_stats.copy(), 

407 "cache_size": len(self._cache), 

408 "max_cache_size": self.config.cache_size, 

409 "performance_metrics": {}, 

410 } 

411 

412 # Calculate average times for each operation 

413 for operation, times in self._performance_metrics.items(): 

414 if times: 

415 metrics["performance_metrics"][operation] = { 

416 "count": len(times), 

417 "average": sum(times) / len(times), 

418 "min": min(times), 

419 "max": max(times), 

420 "total": sum(times), 

421 } 

422 

423 return metrics 

424 

425 def _read_version_from_config_sync(self) -> str: 

426 """ 

427 Synchronous version of reading from .moai/config/config.json. 

428 

429 Returns: 

430 Version string or empty string if not found 

431 """ 

432 try: 

433 if not self._config_path.exists(): 

434 logger.debug(f"Config file not found: {self._config_path}") 

435 return "" 

436 

437 try: 

438 config_data = self._read_json_sync(self._config_path) 

439 version = self._extract_version_from_config(config_data) 

440 return version if version else "" 

441 except json.JSONDecodeError as e: 

442 logger.error(f"Invalid JSON in config {self._config_path}: {e}") 

443 return "" 

444 

445 except Exception as e: 

446 logger.error(f"Error reading version from config: {e}") 

447 return "" 

448 

449 async def _read_version_from_config_async(self) -> str: 

450 """ 

451 Read version from .moai/config/config.json asynchronously. 

452 

453 Returns: 

454 Version string or empty string if not found 

455 """ 

456 try: 

457 if not await self._file_exists_async(self._config_path): 

458 logger.debug(f"Config file not found: {self._config_path}") 

459 return "" 

460 

461 try: 

462 config_data = await self._read_json_async(self._config_path) 

463 return self._extract_version_from_config(config_data) 

464 except json.JSONDecodeError as e: 

465 logger.error(f"Invalid JSON in config {self._config_path}: {e}") 

466 return "" 

467 

468 except Exception as e: 

469 logger.error(f"Error reading version from config: {e}") 

470 return "" 

471 

472 def _extract_version_from_config(self, config: Dict[str, Any]) -> str: 

473 """ 

474 Extract version from config using multiple fallback strategies. 

475 

476 Args: 

477 config: Configuration dictionary 

478 

479 Returns: 

480 Version string or empty string 

481 """ 

482 # Try each version field in order of priority 

483 for field_path in self.VERSION_FIELDS: 

484 version = self._get_nested_value(config, field_path) 

485 if version: 

486 logger.debug(f"Found version in field '{field_path}': {version}") 

487 return version 

488 

489 logger.debug("No version field found in config") 

490 return "" 

491 

492 def _get_nested_value( 

493 self, config: Dict[str, Any], field_path: str 

494 ) -> Optional[str]: 

495 """ 

496 Get nested value from config using dot notation. 

497 

498 Args: 

499 config: Configuration dictionary 

500 field_path: Dot-separated path (e.g., "moai.version") 

501 

502 Returns: 

503 Value or None if not found 

504 """ 

505 keys = field_path.split(".") 

506 current = config 

507 

508 for key in keys: 

509 if isinstance(current, dict) and key in current: 

510 current = current[key] 

511 else: 

512 return None 

513 

514 return str(current) if current is not None else None 

515 

516 def _format_short_version(self, version: str) -> str: 

517 """ 

518 Format short version by removing 'v' prefix if present. 

519 

520 Args: 

521 version: Version string 

522 

523 Returns: 

524 Short version string 

525 """ 

526 return version[1:] if version.startswith("v") else version 

527 

528 def _format_display_version(self, version: str) -> str: 

529 """ 

530 Format display version with proper formatting. 

531 

532 Args: 

533 version: Version string 

534 

535 Returns: 

536 Display version string 

537 """ 

538 if version == "unknown": 

539 return "MoAI-ADK unknown version" 

540 elif version.startswith("v"): 

541 return f"MoAI-ADK {version}" 

542 else: 

543 return f"MoAI-ADK v{version}" 

544 

545 def _is_valid_version_format(self, version: str) -> bool: 

546 """ 

547 Validate version format using regex pattern. 

548 

549 Args: 

550 version: Version string to validate 

551 

552 Returns: 

553 True if version format is valid 

554 """ 

555 return bool(self._version_pattern.match(version)) 

556 

557 def _get_fallback_version(self) -> str: 

558 """ 

559 Get fallback version with graceful degradation. 

560 

561 Returns: 

562 Fallback version string 

563 """ 

564 # Try to get version from package metadata first 

565 pkg_version = self._get_package_version() 

566 if pkg_version: 

567 self._logger.debug(f"Using package metadata version: {pkg_version}") 

568 return pkg_version 

569 

570 # Fall back to configured fallback version 

571 fallback = self.config.fallback_version 

572 self._logger.debug(f"Using configured fallback version: {fallback}") 

573 return fallback 

574 

575 def _get_package_version(self) -> str: 

576 """ 

577 Get version from installed moai-adk package metadata. 

578 

579 This allows the statusline to work even when .moai/config/config.json 

580 is not found, as long as the moai-adk package is installed. 

581 

582 Returns: 

583 Version string or empty string if package not found 

584 """ 

585 try: 

586 from importlib.metadata import PackageNotFoundError, version 

587 

588 try: 

589 pkg_version = version("moai-adk") 

590 self._logger.debug(f"Found moai-adk package version: {pkg_version}") 

591 return pkg_version 

592 except PackageNotFoundError: 

593 self._logger.debug("moai-adk package not found in metadata") 

594 return "" 

595 except ImportError: 

596 self._logger.debug("importlib.metadata not available") 

597 return "" 

598 except Exception as e: 

599 self._logger.debug(f"Error getting package version: {e}") 

600 return "" 

601 

602 

603 async def _file_exists_async(self, path: Path) -> bool: 

604 """Async file existence check""" 

605 try: 

606 loop = asyncio.get_event_loop() 

607 return await loop.run_in_executor(None, path.exists) 

608 except Exception: 

609 return False 

610 

611 async def _read_json_async(self, path: Path) -> Dict[str, Any]: 

612 """Async JSON file reading""" 

613 loop = asyncio.get_event_loop() 

614 return await loop.run_in_executor(None, self._read_json_sync, path) 

615 

616 def _read_json_sync(self, path: Path) -> Dict[str, Any]: 

617 """Synchronous JSON file reading""" 

618 with open(path, "r", encoding="utf-8") as f: 

619 return json.load(f) 

620 

621 # Backwards compatibility cache methods 

622 def clear_cache(self) -> None: 

623 """Clear version cache (backwards compatibility)""" 

624 self._version_cache = None 

625 self._cache_time = None 

626 # Also clear the main cache dictionary 

627 self._cache.clear() 

628 # Reset cache statistics 

629 self._cache_stats = { 

630 "hits": 0, 

631 "misses": 0, 

632 "errors": 0, 

633 "cache_hits_by_source": { 

634 VersionSource.CONFIG_FILE.value: 0, 

635 VersionSource.CACHE.value: 0, 

636 VersionSource.FALLBACK.value: 0, 

637 }, 

638 } 

639 logger.debug("Version cache cleared") 

640 

641 def get_cache_stats(self) -> Dict[str, int]: 

642 """ 

643 Get cache statistics (backwards compatibility). 

644 

645 Returns: 

646 Dictionary with cache hit/miss/error counts 

647 """ 

648 return self._cache_stats.copy() 

649 

650 def get_cache_age_seconds(self) -> Optional[float]: 

651 """ 

652 Get cache age in seconds (backwards compatibility). 

653 

654 Returns: 

655 Cache age in seconds, or None if no cached version 

656 """ 

657 if self._cache_time is None: 

658 return None 

659 return (datetime.now() - self._cache_time).total_seconds() 

660 

661 def is_cache_expired(self) -> bool: 

662 """ 

663 Check if cache is expired (backwards compatibility). 

664 

665 Returns: 

666 True if cache is expired 

667 """ 

668 # Check if cache entry exists and is still valid 

669 config_key = str(self._config_path) 

670 if config_key not in self._cache: 

671 return True 

672 entry = self._cache[config_key] 

673 return not self._is_cache_entry_valid(entry) 

674 

675 def get_config(self) -> VersionConfig: 

676 """Get current configuration""" 

677 return self.config 

678 

679 def update_config(self, config: VersionConfig) -> None: 

680 """ 

681 Update configuration. 

682 

683 Args: 

684 config: New configuration object 

685 """ 

686 self.config = config 

687 self._cache_ttl = timedelta(seconds=self.config.cache_ttl_seconds) 

688 logger.debug("Version reader configuration updated") 

689 

690 def get_available_version_fields(self) -> list[str]: 

691 """ 

692 Get list of available version field paths. 

693 

694 Returns: 

695 List of version field paths 

696 """ 

697 return self.VERSION_FIELDS.copy() 

698 

699 def set_custom_version_fields(self, fields: list[str]) -> None: 

700 """ 

701 Set custom version field paths. 

702 

703 Args: 

704 fields: List of version field paths in order of priority 

705 """ 

706 self.VERSION_FIELDS = fields.copy() 

707 self._version_fields = fields.copy() # Also update internal field list 

708 logger.debug(f"Custom version fields set: {fields}") 

709 

710 

711class VersionReadError(Exception): 

712 """Exception raised when version cannot be read""" 

713 

714 pass