features.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import logging
  2. import dill
  3. import redis
  4. import requests
  5. FEATURES_CACHE_TTL_SEC = 86400
  6. def parse_features_txt(text: str) -> dict:
  7. """Parse features.txt content into a define to state mapping."""
  8. feature_states = {}
  9. for line in text.splitlines():
  10. line = line.strip()
  11. if not line or line.startswith("#"):
  12. continue
  13. if line[0] == "!":
  14. feature_name, state = line[1:], 0
  15. else:
  16. feature_name, state = line, 1
  17. feature_states[feature_name] = state
  18. return feature_states
  19. class FeaturesTxtClient:
  20. """Fetch and cache board default features from firmware-server features.txt."""
  21. def __init__(
  22. self,
  23. redis_host: str = "localhost",
  24. redis_port: str = "6379",
  25. caching_enabled: bool = True,
  26. timeout: int = 30,
  27. user_agent: str = "CustomBuild/1.0",
  28. ):
  29. self.caching_enabled = caching_enabled
  30. self.timeout = timeout
  31. self.user_agent = user_agent
  32. self.logger = logging.getLogger(__name__)
  33. self._cache_key_prefix = "features-"
  34. if self.caching_enabled:
  35. self._redis_client = redis.Redis(
  36. host=redis_host,
  37. port=redis_port,
  38. decode_responses=False,
  39. )
  40. def _cache_key(self, url: str) -> str:
  41. return self._cache_key_prefix + url
  42. def _get_from_cache(self, url: str) -> tuple[str | None, dict | None]:
  43. if not self.caching_enabled:
  44. return None, None
  45. value = self._redis_client.get(self._cache_key(url))
  46. if value is None:
  47. return None, None
  48. entry = dill.loads(value)
  49. if not isinstance(entry, dict):
  50. return None, None
  51. etag = entry.get("etag")
  52. defaults = entry.get("defaults")
  53. if not etag or defaults is None:
  54. return None, None
  55. return etag, defaults
  56. def _store_in_cache(self, url: str, etag: str, defaults: dict) -> None:
  57. if not self.caching_enabled:
  58. return
  59. self._redis_client.set(
  60. name=self._cache_key(url),
  61. value=dill.dumps({"etag": etag, "defaults": defaults}),
  62. ex=FEATURES_CACHE_TTL_SEC,
  63. )
  64. def get_defaults(self, url: str) -> dict | None:
  65. """
  66. Fetch and parse features.txt for the given URL.
  67. Returns a mapping of feature define to state (1 enabled, 0 disabled),
  68. or None if the fetch fails.
  69. """
  70. cached_etag, cached_defaults = self._get_from_cache(url)
  71. headers = {
  72. "User-Agent": self.user_agent,
  73. "Accept-Encoding": "identity",
  74. }
  75. if cached_etag:
  76. headers["If-None-Match"] = cached_etag
  77. try:
  78. response = requests.get(
  79. url,
  80. headers=headers,
  81. timeout=self.timeout,
  82. )
  83. if response.status_code == 304:
  84. return cached_defaults
  85. response.raise_for_status()
  86. defaults = parse_features_txt(response.text)
  87. enabled_count = sum(1 for state in defaults.values() if state)
  88. disabled_count = len(defaults) - enabled_count
  89. self.logger.info(
  90. "Fetched board defaults from firmware server: "
  91. "%d enabled, %d disabled",
  92. enabled_count,
  93. disabled_count,
  94. )
  95. etag = response.headers.get("ETag")
  96. if etag:
  97. self._store_in_cache(url, etag, defaults)
  98. return defaults
  99. except requests.RequestException as exc:
  100. self.logger.warning(
  101. "Failed to fetch board defaults from %s: %s", url, exc
  102. )
  103. return None