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
« 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
4Refactored for improved performance, error handling, configurability, and caching strategies
5"""
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
19logger = logging.getLogger(__name__)
22class VersionSource(Enum):
23 """Enum for version source tracking"""
25 CONFIG_FILE = "config_file"
26 FALLBACK = "fallback"
27 PACKAGE = "package"
28 CACHE = "cache"
31@dataclass
32class CacheEntry:
33 """Cache entry with metadata"""
35 version: str
36 timestamp: datetime
37 source: VersionSource
38 access_count: int = 0
39 last_access: datetime = field(default_factory=datetime.now)
42@dataclass
43class VersionConfig:
44 """Configuration for version reading behavior with enhanced options"""
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
52 # Fallback configuration
53 fallback_version: str = "unknown"
54 fallback_source: VersionSource = VersionSource.FALLBACK
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
61 # Performance configuration
62 enable_async: bool = True
63 enable_batch_reading: bool = True
64 batch_size: int = 10
65 timeout_seconds: int = 5
67 # Debug configuration
68 debug_mode: bool = False
69 enable_detailed_logging: bool = False
70 track_performance_metrics: bool = True
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 )
85class VersionReader:
86 """
87 Enhanced version reader for MoAI-ADK with advanced caching,
88 performance optimization, and comprehensive error handling.
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 """
101 # Default configuration
102 DEFAULT_CONFIG = VersionConfig()
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 ]
114 def __init__(self, config: Optional[VersionConfig] = None, working_dir: Optional[Path] = None):
115 """
116 Initialize version reader with enhanced configuration.
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
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()
135 self._config_path = base_dir / ".moai" / "config" / "config.json"
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 }
150 # Performance tracking
151 self._performance_metrics = {
152 "read_times": [],
153 "validation_times": [],
154 "cache_operation_times": [],
155 }
157 # Version field configuration (backwards compatibility)
158 self._version_fields = self.config.version_fields.copy()
159 self.VERSION_FIELDS = self._version_fields.copy()
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)
167 # Logging
168 self._logger = logging.getLogger(__name__)
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)
175 if self.config.debug_mode:
176 self._logger.info(f"VersionReader initialized with config: {self.config}")
178 def get_version(self) -> str:
179 """
180 Get MoAI-ADK version from config with enhanced caching.
182 Returns:
183 Version string (e.g., "0.20.1" or "v0.20.1")
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()
193 def get_version_sync(self) -> str:
194 """
195 Synchronous version getter for performance-critical paths.
197 Returns:
198 Version string
199 """
200 start_time = time.time()
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
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
220 except Exception as e:
221 self._handle_read_error(e, start_time)
222 return self._get_fallback_version()
224 finally:
225 self._log_performance(start_time, "sync_read")
227 async def get_version_async(self) -> str:
228 """
229 Async version getter for better performance.
231 Returns:
232 Version string
233 """
234 start_time = time.time()
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
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
254 except Exception as e:
255 self._handle_read_error(e, start_time)
256 return self._get_fallback_version()
258 finally:
259 self._log_performance(start_time, "async_read")
261 # Enhanced internal methods
262 def _check_cache(self) -> Optional[str]:
263 """
264 Check cache for valid version entry.
266 Returns:
267 Version string if cache is valid, None otherwise
268 """
269 if not self.config.cache_enabled:
270 return None
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]
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
286 if self.config.debug_mode:
287 self._logger.debug(
288 f"Cache hit: {entry.version} (source: {entry.source.value})"
289 )
291 return entry.version
293 return None
295 def _is_cache_entry_valid(self, entry: CacheEntry) -> bool:
296 """
297 Check if cache entry is still valid.
299 Args:
300 entry: Cache entry to validate
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
311 return True
313 def _update_cache(self, version: str, source: VersionSource) -> None:
314 """
315 Update cache with new version entry.
317 Args:
318 version: Version string to cache
319 source: Source of the version
320 """
321 if not self.config.cache_enabled:
322 return
324 config_key = str(self._config_path)
325 entry = CacheEntry(version=version, timestamp=datetime.now(), source=source)
327 self._cache[config_key] = entry
329 # Apply cache size limits with LRU eviction
330 if len(self._cache) > self.config.cache_size:
331 self._evict_oldest_cache_entry()
333 if self.config.debug_mode:
334 self._logger.debug(
335 f"Cache updated with version: {version} (source: {source.value})"
336 )
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
345 oldest_entry = None
346 oldest_key = None
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
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}")
358 def _handle_read_error(self, error: Exception, start_time: float) -> None:
359 """
360 Handle read errors with enhanced logging and recovery.
362 Args:
363 error: Exception that occurred
364 start_time: When the operation started
365 """
366 self._cache_stats["errors"] += 1
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)
374 self._log_performance(start_time, "error_read")
376 def _log_performance(self, start_time: float, operation: str) -> None:
377 """
378 Log performance metrics for the operation.
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
387 duration = time.time() - start_time
388 metric_name = f"{operation}_duration"
390 if metric_name not in self._performance_metrics:
391 self._performance_metrics[metric_name] = []
393 self._performance_metrics[metric_name].append(duration)
395 if self.config.debug_mode:
396 self._logger.debug(f"Performance {operation}: {duration:.4f}s")
398 def get_performance_metrics(self) -> Dict[str, Any]:
399 """
400 Get performance metrics for analysis.
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 }
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 }
423 return metrics
425 def _read_version_from_config_sync(self) -> str:
426 """
427 Synchronous version of reading from .moai/config/config.json.
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 ""
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 ""
445 except Exception as e:
446 logger.error(f"Error reading version from config: {e}")
447 return ""
449 async def _read_version_from_config_async(self) -> str:
450 """
451 Read version from .moai/config/config.json asynchronously.
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 ""
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 ""
468 except Exception as e:
469 logger.error(f"Error reading version from config: {e}")
470 return ""
472 def _extract_version_from_config(self, config: Dict[str, Any]) -> str:
473 """
474 Extract version from config using multiple fallback strategies.
476 Args:
477 config: Configuration dictionary
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
489 logger.debug("No version field found in config")
490 return ""
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.
498 Args:
499 config: Configuration dictionary
500 field_path: Dot-separated path (e.g., "moai.version")
502 Returns:
503 Value or None if not found
504 """
505 keys = field_path.split(".")
506 current = config
508 for key in keys:
509 if isinstance(current, dict) and key in current:
510 current = current[key]
511 else:
512 return None
514 return str(current) if current is not None else None
516 def _format_short_version(self, version: str) -> str:
517 """
518 Format short version by removing 'v' prefix if present.
520 Args:
521 version: Version string
523 Returns:
524 Short version string
525 """
526 return version[1:] if version.startswith("v") else version
528 def _format_display_version(self, version: str) -> str:
529 """
530 Format display version with proper formatting.
532 Args:
533 version: Version string
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}"
545 def _is_valid_version_format(self, version: str) -> bool:
546 """
547 Validate version format using regex pattern.
549 Args:
550 version: Version string to validate
552 Returns:
553 True if version format is valid
554 """
555 return bool(self._version_pattern.match(version))
557 def _get_fallback_version(self) -> str:
558 """
559 Get fallback version with graceful degradation.
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
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
575 def _get_package_version(self) -> str:
576 """
577 Get version from installed moai-adk package metadata.
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.
582 Returns:
583 Version string or empty string if package not found
584 """
585 try:
586 from importlib.metadata import PackageNotFoundError, version
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 ""
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
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)
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)
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")
641 def get_cache_stats(self) -> Dict[str, int]:
642 """
643 Get cache statistics (backwards compatibility).
645 Returns:
646 Dictionary with cache hit/miss/error counts
647 """
648 return self._cache_stats.copy()
650 def get_cache_age_seconds(self) -> Optional[float]:
651 """
652 Get cache age in seconds (backwards compatibility).
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()
661 def is_cache_expired(self) -> bool:
662 """
663 Check if cache is expired (backwards compatibility).
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)
675 def get_config(self) -> VersionConfig:
676 """Get current configuration"""
677 return self.config
679 def update_config(self, config: VersionConfig) -> None:
680 """
681 Update configuration.
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")
690 def get_available_version_fields(self) -> list[str]:
691 """
692 Get list of available version field paths.
694 Returns:
695 List of version field paths
696 """
697 return self.VERSION_FIELDS.copy()
699 def set_custom_version_fields(self, fields: list[str]) -> None:
700 """
701 Set custom version field paths.
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}")
711class VersionReadError(Exception):
712 """Exception raised when version cannot be read"""
714 pass