client.py 4.7 KB

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