version_manager.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """
  2. Version management for Dune Weaver
  3. Handles current version reading and GitHub API integration for latest version checking
  4. Testing overrides (environment variables):
  5. FORCE_UPDATE_AVAILABLE=1 - Force update to appear available
  6. FAKE_LATEST_VERSION=5.0.0 - Override the "latest" version for testing
  7. """
  8. import asyncio
  9. import aiohttp
  10. import json
  11. import os
  12. import time
  13. from pathlib import Path
  14. from typing import Dict, Optional
  15. import logging
  16. logger = logging.getLogger(__name__)
  17. # Testing overrides via environment variables
  18. FORCE_UPDATE_AVAILABLE = os.environ.get("FORCE_UPDATE_AVAILABLE", "").lower() in ("1", "true", "yes")
  19. FAKE_LATEST_VERSION = os.environ.get("FAKE_LATEST_VERSION", "")
  20. if FORCE_UPDATE_AVAILABLE or FAKE_LATEST_VERSION:
  21. logger.warning(f"Version override active: FORCE_UPDATE_AVAILABLE={FORCE_UPDATE_AVAILABLE}, FAKE_LATEST_VERSION={FAKE_LATEST_VERSION}")
  22. class VersionManager:
  23. def __init__(self):
  24. self.repo_owner = "tuanchris"
  25. self.repo_name = "dune-weaver"
  26. self.github_api_url = f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}"
  27. self._current_version = None
  28. # Caching for GitHub API to avoid rate limits and slow requests
  29. self._latest_release_cache = None
  30. self._cache_timestamp = None
  31. self._cache_duration = 3600 # Cache for 1 hour (in seconds)
  32. async def get_current_version(self) -> str:
  33. """Read current version from VERSION file (async)"""
  34. if self._current_version is None:
  35. try:
  36. version_file = Path(__file__).parent.parent.parent / "VERSION"
  37. if version_file.exists():
  38. self._current_version = await asyncio.to_thread(version_file.read_text)
  39. self._current_version = self._current_version.strip()
  40. else:
  41. logger.warning("VERSION file not found, using default version")
  42. self._current_version = "1.0.0"
  43. except Exception as e:
  44. logger.error(f"Error reading VERSION file: {e}")
  45. self._current_version = "1.0.0"
  46. return self._current_version
  47. async def get_latest_release(self, force_refresh: bool = False) -> Dict[str, any]:
  48. """Get latest release info from GitHub API with caching"""
  49. # Check if we have a valid cache
  50. current_time = time.time()
  51. if not force_refresh and self._latest_release_cache is not None and self._cache_timestamp is not None:
  52. cache_age = current_time - self._cache_timestamp
  53. if cache_age < self._cache_duration:
  54. logger.debug(f"Returning cached version info (age: {cache_age:.0f}s)")
  55. return self._latest_release_cache
  56. # Cache miss or expired - fetch from GitHub
  57. logger.info("Fetching latest release from GitHub API")
  58. try:
  59. async with aiohttp.ClientSession() as session:
  60. async with session.get(
  61. f"{self.github_api_url}/releases/latest",
  62. timeout=aiohttp.ClientTimeout(total=10)
  63. ) as response:
  64. if response.status == 200:
  65. data = await response.json()
  66. release_data = {
  67. "version": data.get("tag_name", "").lstrip("v"),
  68. "name": data.get("name", ""),
  69. "published_at": data.get("published_at", ""),
  70. "html_url": data.get("html_url", ""),
  71. "body": data.get("body", ""),
  72. "prerelease": data.get("prerelease", False)
  73. }
  74. # Update cache
  75. self._latest_release_cache = release_data
  76. self._cache_timestamp = current_time
  77. logger.info(f"Cached new release info: {release_data.get('version')}")
  78. return release_data
  79. elif response.status == 404:
  80. # No releases found
  81. logger.info("No releases found on GitHub")
  82. return None
  83. else:
  84. logger.warning(f"GitHub API returned status {response.status}")
  85. # Return cached data if available, even if stale
  86. return self._latest_release_cache
  87. except asyncio.TimeoutError:
  88. logger.warning("Timeout while fetching latest release from GitHub")
  89. # Return cached data if available
  90. return self._latest_release_cache
  91. except Exception as e:
  92. logger.error(f"Error fetching latest release: {e}")
  93. # Return cached data if available
  94. return self._latest_release_cache
  95. def compare_versions(self, version1: str, version2: str) -> int:
  96. """Compare two semantic versions. Returns -1, 0, or 1"""
  97. try:
  98. # Parse semantic versions (e.g., "1.2.3")
  99. v1_parts = [int(x) for x in version1.split('.')]
  100. v2_parts = [int(x) for x in version2.split('.')]
  101. # Pad shorter version with zeros
  102. max_len = max(len(v1_parts), len(v2_parts))
  103. v1_parts.extend([0] * (max_len - len(v1_parts)))
  104. v2_parts.extend([0] * (max_len - len(v2_parts)))
  105. if v1_parts < v2_parts:
  106. return -1
  107. elif v1_parts > v2_parts:
  108. return 1
  109. else:
  110. return 0
  111. except (ValueError, AttributeError):
  112. logger.warning(f"Invalid version format: {version1} vs {version2}")
  113. return 0
  114. async def get_version_info(self, force_refresh: bool = False) -> Dict[str, any]:
  115. """Get complete version information
  116. Args:
  117. force_refresh: If True, bypass cache and fetch from GitHub API
  118. """
  119. current = await self.get_current_version()
  120. latest_release = await self.get_latest_release(force_refresh=force_refresh)
  121. if latest_release:
  122. latest = latest_release["version"]
  123. comparison = self.compare_versions(current, latest)
  124. update_available = comparison < 0
  125. else:
  126. latest = current # Fallback if no releases found
  127. update_available = False
  128. return {
  129. "current": current,
  130. "latest": latest,
  131. "update_available": update_available,
  132. "latest_release": latest_release
  133. }
  134. def clear_cache(self):
  135. """Clear the cached version data"""
  136. self._latest_release_cache = None
  137. self._cache_timestamp = None
  138. logger.info("Version cache cleared")
  139. # Global instance
  140. version_manager = VersionManager()