| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- import logging
- import re
- from collections import defaultdict
- from typing import Optional
- from urllib.parse import urlparse
- from packaging.version import InvalidVersion, Version
- from .models import BoardArtifact, ReleaseRecord
- FIRMWARE_SERVER_BASE = "https://firmware.ardupilot.org"
- # CBS vehicle id -> firmware.ardupilot.org top-level directory.
- FIRMWARE_SERVER_DIR_BY_VEHICLE_ID = {
- "copter": "Copter",
- "plane": "Plane",
- "rover": "Rover",
- "sub": "Sub",
- "heli": "Copter",
- "blimp": "Blimp",
- "tracker": "AntennaTracker",
- "ap-periph": "AP_Periph",
- }
- def firmware_server_dir(vehicle_id: str) -> str:
- try:
- return FIRMWARE_SERVER_DIR_BY_VEHICLE_ID[vehicle_id]
- except KeyError as exc:
- raise ValueError(f"Unknown vehicle id: {vehicle_id}") from exc
- def latest_features_txt_url(vehicle_id: str, board_id: str) -> str:
- """Hardcoded firmware-server latest features.txt URL for tag builds."""
- board_subdir = board_id + ("-heli" if vehicle_id == "heli" else "")
- return (
- f"{FIRMWARE_SERVER_BASE}/"
- f"{firmware_server_dir(vehicle_id)}/latest/"
- f"{board_subdir}/features.txt"
- )
- # Minimum version for a vehicle to expose from manifest entries.
- MIN_VERSION_BY_VEHICLE_ID = {
- "copter": "4.3",
- "plane": "4.3",
- "rover": "4.3",
- "sub": "4.3",
- "tracker": "4.3",
- "blimp": "4.3",
- "heli": "4.3",
- "ap-periph": "1.8.1",
- }
- def vehicle_id_for_manifest_entry(entry: dict) -> Optional[str]:
- match entry.get("vehicletype", ""):
- case "Copter":
- return "heli" if entry.get("mav-type") == "HELICOPTER" else "copter"
- case "Plane":
- return "plane"
- case "Rover":
- return "rover"
- case "Sub":
- return "sub"
- case "Blimp":
- return "blimp"
- case "AntennaTracker":
- return "tracker"
- case "AP_Periph":
- return "ap-periph"
- case _:
- return None
- def _should_skip_version(
- vehicle_id: str, release_type: str, manifest_version: str
- ) -> bool:
- if release_type == "latest":
- return False
- min_version = MIN_VERSION_BY_VEHICLE_ID.get(vehicle_id)
- if not min_version:
- return False
- try:
- return Version(manifest_version) < Version(min_version)
- except InvalidVersion:
- return True
- def map_manifest_release_type(mav_firmware_version_type: str) -> Optional[str]:
- if not mav_firmware_version_type:
- return None
- if mav_firmware_version_type.startswith("STABLE-"):
- return "stable"
- if mav_firmware_version_type == "OFFICIAL":
- return "stable"
- if mav_firmware_version_type == "BETA":
- return "beta"
- if mav_firmware_version_type == "DEV":
- return "latest"
- return mav_firmware_version_type.lower()
- def parse_artifacts_base_url(url: str) -> Optional[str]:
- match = re.match(
- r"(https://firmware\.ardupilot\.org/[^/]+/[^/]+)/",
- url or "",
- )
- if match:
- return match.group(1)
- return None
- def manifest_platform_key(platform: str, vehicle_id: str) -> str:
- """Normalize manifest platform field to the board id used for lookups."""
- if vehicle_id == "heli" and platform.endswith("-heli"):
- return platform[:-5]
- return platform
- def _artifact_name_from_url(url: str) -> str:
- return urlparse(url).path.rstrip("/").split("/")[-1]
- def _versioned_release_path_segment(release_type: str, version_number: str) -> str:
- if version_number == "NA":
- return release_type
- return f"{release_type}-{version_number}"
- def _artifact_url_specificity(url: str, release_type: str, version_number: str) -> int:
- """Prefer versioned firmware-server paths over generic release aliases."""
- segment = _versioned_release_path_segment(release_type, version_number)
- if f"/{segment}/" in url:
- return 2
- if f"/{release_type}/" in url:
- return 1
- return 0
- def _dedupe_board_artifacts(
- artifacts: list[BoardArtifact],
- release_type: str,
- version_number: str,
- ) -> list[BoardArtifact]:
- """Manifest entries may alias the same file under versioned and generic URLs."""
- by_name: dict[str, BoardArtifact] = {}
- for artifact in artifacts:
- existing = by_name.get(artifact.name)
- if existing is None or _artifact_url_specificity(
- artifact.url, release_type, version_number
- ) > _artifact_url_specificity(existing.url, release_type, version_number):
- by_name[artifact.name] = artifact
- return list(by_name.values())
- def _release_fields_from_entry(
- entry: dict,
- ) -> Optional[tuple[str, str, str, str]]:
- vehicle_id = vehicle_id_for_manifest_entry(entry)
- if vehicle_id is None:
- return None
- release_type = map_manifest_release_type(
- entry.get("mav-firmware-version-type", "")
- )
- manifest_version = entry.get("mav-firmware-version")
- if not release_type or not manifest_version:
- return None
- if _should_skip_version(vehicle_id, release_type, manifest_version):
- return None
- base_url = parse_artifacts_base_url(entry.get("url", ""))
- if not base_url:
- return None
- git_sha = entry.get("git-sha")
- if not git_sha:
- return None
- version_number = "NA" if release_type == "latest" else manifest_version
- return vehicle_id, release_type, version_number, git_sha
- def _record_release_meta(
- release_meta: dict[tuple, dict],
- vehicle_id: str,
- release_type: str,
- version_number: str,
- git_sha: str,
- logger: logging.Logger,
- ) -> None:
- key = (vehicle_id, release_type, version_number)
- if key not in release_meta:
- release_meta[key] = {
- "vehicle_id": vehicle_id,
- "release_type": release_type,
- "version_number": version_number,
- "git_sha": git_sha,
- }
- return
- if release_meta[key]["git_sha"] != git_sha:
- logger.debug(
- "Conflicting git-sha for %s: keeping %s, ignoring %s",
- key,
- release_meta[key]["git_sha"][:8],
- git_sha[:8],
- )
- def _record_board_artifact(
- artifacts_by_release: dict[tuple[str, str, str], dict[str, list[BoardArtifact]]],
- entry: dict,
- vehicle_id: str,
- release_type: str,
- version_number: str,
- ) -> None:
- platform = entry.get("platform")
- artifact_url = entry.get("url", "")
- if not platform or not artifact_url:
- return
- platform = manifest_platform_key(platform, vehicle_id)
- key = (vehicle_id, release_type, version_number)
- artifacts_by_release[key][platform].append(
- BoardArtifact(
- name=_artifact_name_from_url(artifact_url),
- url=artifact_url,
- format=entry.get("format", ""),
- size=entry.get("image_size"),
- )
- )
- def _releases_from_meta(
- release_meta: dict[tuple, dict],
- ) -> dict[str, list[ReleaseRecord]]:
- releases_by_vehicle: dict[str, list[ReleaseRecord]] = defaultdict(list)
- for meta in release_meta.values():
- releases_by_vehicle[meta["vehicle_id"]].append(
- ReleaseRecord(
- vehicle_id=meta["vehicle_id"],
- release_type=meta["release_type"],
- version_number=meta["version_number"],
- commit_reference=meta["git_sha"],
- )
- )
- for vehicle_id in releases_by_vehicle:
- releases_by_vehicle[vehicle_id].sort(
- key=lambda r: (
- 0 if r.release_type == "latest" else 1,
- r.release_type,
- _version_sort_key(r.version_number),
- )
- )
- return dict(releases_by_vehicle)
- class ManifestIndex:
- """In-memory index built from a parsed manifest document."""
- def __init__(
- self,
- releases_by_vehicle: dict[str, list[ReleaseRecord]],
- artifacts_by_release: dict[
- tuple[str, str, str], dict[str, list[BoardArtifact]]
- ],
- ):
- self.releases_by_vehicle = releases_by_vehicle
- self.artifacts_by_release = artifacts_by_release
- @classmethod
- def build(cls, manifest: dict) -> "ManifestIndex":
- logger = logging.getLogger(__name__)
- release_meta: dict[tuple, dict] = {}
- artifacts_by_release: dict[
- tuple[str, str, str], dict[str, list[BoardArtifact]]
- ] = defaultdict(lambda: defaultdict(list))
- for entry in manifest.get("firmware") or []:
- fields = _release_fields_from_entry(entry)
- if fields is None:
- continue
- vehicle_id, release_type, version_number, git_sha = fields
- _record_release_meta(
- release_meta,
- vehicle_id,
- release_type,
- version_number,
- git_sha,
- logger,
- )
- _record_board_artifact(
- artifacts_by_release,
- entry,
- vehicle_id,
- release_type,
- version_number,
- )
- return cls(
- _releases_from_meta(release_meta),
- {
- release_key: dict(platforms)
- for release_key, platforms in artifacts_by_release.items()
- },
- )
- def get_releases(self, vehicle_id: str) -> list[ReleaseRecord]:
- return list(self.releases_by_vehicle.get(vehicle_id, []))
- def get_board_artifacts(
- self,
- vehicle_id: str,
- release_type: str,
- version_number: str,
- board_id: str,
- ) -> list[BoardArtifact]:
- key = (vehicle_id, release_type, version_number)
- artifacts = list(self.artifacts_by_release.get(key, {}).get(board_id, []))
- return _dedupe_board_artifacts(artifacts, release_type, version_number)
- def get_features_txt_url(
- self,
- vehicle_id: str,
- release_type: str,
- version_number: str,
- board_id: str,
- ) -> Optional[str]:
- artifacts = self.get_board_artifacts(
- vehicle_id, release_type, version_number, board_id
- )
- if not artifacts:
- return None
- return artifacts[0].url.rsplit("/", 1)[0] + "/features.txt"
- def _version_sort_key(version_number: str) -> tuple:
- if version_number == "NA":
- return (0,)
- try:
- parsed = Version(version_number)
- return (1, parsed.major, parsed.minor, parsed.micro)
- except InvalidVersion:
- return (2, version_number)
|