client.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import json
  2. import logging
  3. import os
  4. from dataclasses import dataclass
  5. from datetime import datetime, timezone
  6. from pathlib import Path
  7. from typing import Optional
  8. import requests
  9. from .exceptions import ManifestFetchError
  10. @dataclass
  11. class _CacheMeta:
  12. etag: Optional[str] = None
  13. last_modified: Optional[str] = None
  14. fetched_at: Optional[str] = None
  15. def to_dict(self) -> dict:
  16. return {
  17. "etag": self.etag,
  18. "last_modified": self.last_modified,
  19. "fetched_at": self.fetched_at,
  20. }
  21. @classmethod
  22. def from_dict(cls, data: dict) -> "_CacheMeta":
  23. return cls(
  24. etag=data.get("etag"),
  25. last_modified=data.get("last_modified"),
  26. fetched_at=data.get("fetched_at"),
  27. )
  28. class ManifestClient:
  29. """Fetch and cache the ArduPilot firmware manifest.json file."""
  30. def __init__(
  31. self,
  32. url: str,
  33. cache_dir: str,
  34. timeout: int = 120,
  35. user_agent: str = "CustomBuild/1.0",
  36. ):
  37. self.url = url
  38. self.cache_dir = Path(cache_dir)
  39. self.cache_path = self.cache_dir / "manifest.json"
  40. self.meta_path = self.cache_dir / "manifest.json.meta"
  41. self.timeout = timeout
  42. self.user_agent = user_agent
  43. self.logger = logging.getLogger(__name__)
  44. def fetch_raw(self) -> bytes:
  45. headers = {"User-Agent": self.user_agent}
  46. meta = self._read_meta() if self._has_cache() else _CacheMeta()
  47. if meta.etag:
  48. headers["If-None-Match"] = meta.etag
  49. if meta.last_modified:
  50. headers["If-Modified-Since"] = meta.last_modified
  51. try:
  52. response = requests.get(
  53. self.url,
  54. headers=headers,
  55. timeout=self.timeout,
  56. )
  57. except requests.RequestException as exc:
  58. return self._fallback_or_raise(exc)
  59. if response.status_code == 304:
  60. self.logger.info("Manifest not modified (304), using cache")
  61. self._touch_fetched_at(self._now_iso())
  62. return self._read_cache_bytes()
  63. if response.status_code != 200:
  64. return self._fallback_or_raise(
  65. ManifestFetchError(
  66. f"Manifest fetch failed with status {response.status_code}"
  67. )
  68. )
  69. raw = response.content
  70. self._write_cache(
  71. raw,
  72. _CacheMeta(
  73. etag=response.headers.get("ETag"),
  74. last_modified=response.headers.get("Last-Modified"),
  75. fetched_at=self._now_iso(),
  76. ),
  77. )
  78. self.logger.info("Downloaded manifest (%d bytes)", len(raw))
  79. return raw
  80. def fetch(self) -> dict:
  81. return json.loads(self.fetch_raw().decode("utf-8"))
  82. def _has_cache(self) -> bool:
  83. return self.cache_path.is_file()
  84. def _read_cache_bytes(self) -> bytes:
  85. return self.cache_path.read_bytes()
  86. def _read_meta(self) -> _CacheMeta:
  87. if not self.meta_path.is_file():
  88. return _CacheMeta()
  89. return _CacheMeta.from_dict(
  90. json.loads(self.meta_path.read_text(encoding="utf-8"))
  91. )
  92. def _write_cache(self, raw: bytes, meta: _CacheMeta) -> None:
  93. self._atomic_write_bytes(self.cache_path, raw)
  94. self._atomic_write_text(
  95. self.meta_path,
  96. json.dumps(meta.to_dict(), indent=2),
  97. )
  98. def _touch_fetched_at(self, fetched_at: str) -> None:
  99. meta = self._read_meta()
  100. meta.fetched_at = fetched_at
  101. self._atomic_write_text(
  102. self.meta_path,
  103. json.dumps(meta.to_dict(), indent=2),
  104. )
  105. def _atomic_write_bytes(self, path: Path, raw: bytes) -> None:
  106. path.parent.mkdir(parents=True, exist_ok=True)
  107. tmp_path = path.with_name(f"{path.name}.tmp")
  108. tmp_path.write_bytes(raw)
  109. os.replace(tmp_path, path)
  110. def _atomic_write_text(self, path: Path, text: str) -> None:
  111. path.parent.mkdir(parents=True, exist_ok=True)
  112. tmp_path = path.with_name(f"{path.name}.tmp")
  113. tmp_path.write_text(text, encoding="utf-8")
  114. os.replace(tmp_path, path)
  115. def _fallback_or_raise(self, exc: Exception) -> bytes:
  116. if self._has_cache():
  117. self.logger.warning(
  118. "Manifest fetch failed (%s), using stale cache", exc
  119. )
  120. return self._read_cache_bytes()
  121. raise ManifestFetchError(str(exc)) from exc
  122. @staticmethod
  123. def _now_iso() -> str:
  124. return datetime.now(timezone.utc).isoformat()