core.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import logging
  2. import time
  3. import os
  4. import fnmatch
  5. import ap_git
  6. import json
  7. import jsonschema
  8. from pathlib import Path
  9. from . import exceptions as ex
  10. from threading import Lock
  11. from utils import TaskRunner
  12. logger = logging.getLogger(__name__)
  13. class APSourceMetadataFetcher:
  14. """
  15. Class to fetch metadata like available boards, features etc.
  16. from the AP source code
  17. """
  18. __singleton = None
  19. def __init__(self, ap_repo: ap_git.GitRepo) -> None:
  20. """
  21. Initializes the APSourceMetadataFetcher instance
  22. with a given repository path.
  23. Parameters:
  24. ap_repo (GitRepo): ArduPilot local git repository containing
  25. the metadata generation scripts.
  26. Raises:
  27. TooManyInstancesError: If an instance of this class already exists,
  28. enforcing a singleton pattern.
  29. """
  30. # Enforce singleton pattern by raising an error if
  31. # an instance already exists.
  32. if APSourceMetadataFetcher.__singleton:
  33. raise ex.TooManyInstancesError()
  34. self.repo = ap_repo
  35. APSourceMetadataFetcher.__singleton = self
  36. def get_boards_at_commit(self, remote: str,
  37. commit_ref: str) -> list:
  38. """
  39. Retrieves a list of boards available for building at a
  40. specified commit and returns the list and the default board.
  41. Parameters:
  42. remote (str): The name of the remote repository.
  43. commit_ref (str): The commit reference to check out.
  44. Returns:
  45. list: A list of boards available at the specified commit.
  46. """
  47. tstart = time.time()
  48. import importlib.util
  49. with self.repo.get_checkout_lock():
  50. self.repo.checkout_remote_commit_ref(
  51. remote=remote,
  52. commit_ref=commit_ref,
  53. force=True,
  54. hard_reset=True,
  55. clean_working_tree=True
  56. )
  57. spec = importlib.util.spec_from_file_location(
  58. name="board_list.py",
  59. location=os.path.join(
  60. self.repo.get_local_path(),
  61. 'Tools', 'scripts',
  62. 'board_list.py')
  63. )
  64. mod = importlib.util.module_from_spec(spec)
  65. spec.loader.exec_module(mod)
  66. all_boards = mod.AUTOBUILD_BOARDS
  67. exclude_patterns = ['fmuv*', 'SITL*']
  68. boards = []
  69. for b in all_boards:
  70. excluded = False
  71. for p in exclude_patterns:
  72. if fnmatch.fnmatch(b.lower(), p.lower()):
  73. excluded = True
  74. break
  75. if not excluded:
  76. boards.append(b)
  77. logger.debug(
  78. f"Took {(time.time() - tstart)} seconds to get boards"
  79. )
  80. boards.sort()
  81. return boards
  82. def get_build_options_at_commit(self, remote: str,
  83. commit_ref: str) -> list:
  84. """
  85. Retrieves a list of build options available at a specified commit.
  86. Parameters:
  87. remote (str): The name of the remote repository.
  88. commit_ref (str): The commit reference to check out.
  89. Returns:
  90. list: A list of build options available at the specified commit.
  91. """
  92. tstart = time.time()
  93. import importlib.util
  94. with self.repo.get_checkout_lock():
  95. self.repo.checkout_remote_commit_ref(
  96. remote=remote,
  97. commit_ref=commit_ref,
  98. force=True,
  99. hard_reset=True,
  100. clean_working_tree=True
  101. )
  102. spec = importlib.util.spec_from_file_location(
  103. name="build_options.py",
  104. location=os.path.join(
  105. self.repo.get_local_path(),
  106. 'Tools',
  107. 'scripts',
  108. 'build_options.py'
  109. )
  110. )
  111. mod = importlib.util.module_from_spec(spec)
  112. spec.loader.exec_module(mod)
  113. build_options = mod.BUILD_OPTIONS
  114. logger.debug(
  115. f"Took {(time.time() - tstart)} seconds to get build options"
  116. )
  117. return build_options
  118. @staticmethod
  119. def get_singleton():
  120. return APSourceMetadataFetcher.__singleton
  121. class VersionInfo:
  122. """
  123. Class to wrap version info properties inside a single object
  124. """
  125. def __init__(self,
  126. remote: str,
  127. commit_ref: str,
  128. release_type: str,
  129. version_number: str,
  130. ap_build_artifacts_url) -> None:
  131. self.remote = remote
  132. self.commit_ref = commit_ref
  133. self.release_type = release_type
  134. self.version_number = version_number
  135. self.ap_build_artifacts_url = ap_build_artifacts_url
  136. class RemoteInfo:
  137. """
  138. Class to wrap remote info properties inside a single object
  139. """
  140. def __init__(self,
  141. name: str,
  142. url: str) -> None:
  143. self.name = name
  144. self.url = url
  145. class VersionsFetcher:
  146. """
  147. Class to fetch the version-to-build metadata from remotes.json
  148. and provide methods to view the same
  149. """
  150. __singleton = None
  151. def __init__(self, remotes_json_path: str,
  152. ap_repo: ap_git.GitRepo):
  153. """
  154. Initializes the VersionsFetcher instance
  155. with a given remotes.json path.
  156. Parameters:
  157. remotes_json_path (str): Path to the remotes.json file.
  158. ap_repo (GitRepo): ArduPilot local git repository. This local
  159. repository is shared between the VersionsFetcher
  160. and the APSourceMetadataFetcher.
  161. Raises:
  162. TooManyInstancesError: If an instance of this class already exists,
  163. enforcing a singleton pattern.
  164. """
  165. # Enforce singleton pattern by raising an error if
  166. # an instance already exists.
  167. if VersionsFetcher.__singleton:
  168. raise ex.TooManyInstancesError()
  169. self.__remotes_json_path = remotes_json_path
  170. self.__ensure_remotes_json()
  171. self.__access_lock_versions_metadata = Lock()
  172. self.__versions_metadata = []
  173. tasks = (
  174. (self.fetch_ap_releases, 1200),
  175. (self.fetch_whitelisted_tags, 1200),
  176. )
  177. self.__task__runner = TaskRunner(tasks=tasks)
  178. self.repo = ap_repo
  179. VersionsFetcher.__singleton = self
  180. def start(self) -> None:
  181. """
  182. Start auto-fetch jobs.
  183. """
  184. logger.info("Starting VersionsFetcher background auto-fetch jobs.")
  185. self.__task__runner.start()
  186. def get_all_remotes_info(self) -> list[RemoteInfo]:
  187. """
  188. Return the list of RemoteInfo objects constructed from the
  189. information in the remotes.json file
  190. Returns:
  191. list: RemoteInfo objects for all remotes mentioned in remotes.json
  192. """
  193. return [
  194. RemoteInfo(
  195. name=remote.get('name', None),
  196. url=remote.get('url', None)
  197. )
  198. for remote in self.__get_versions_metadata()
  199. ]
  200. def get_remote_info(self, remote_name: str) -> RemoteInfo:
  201. """
  202. Return the RemoteInfo for the given remote name, None otherwise.
  203. Returns:
  204. RemoteInfo: The remote information object.
  205. """
  206. return next(
  207. (
  208. remote for remote in self.get_all_remotes_info()
  209. if remote.name == remote_name
  210. ),
  211. None
  212. )
  213. def get_versions_for_vehicle(self, vehicle_name: str) -> list[VersionInfo]:
  214. """
  215. Return the list of dictionaries containing the info about the
  216. versions listed to be built for a particular vehicle.
  217. Parameters:
  218. vehicle_name (str): the vehicle to fetch versions list for
  219. Returns:
  220. list: VersionInfo objects for all versions allowed to be
  221. built for the said vehicle.
  222. """
  223. if vehicle_name is None:
  224. raise ValueError("Vehicle is a required parameter.")
  225. versions_list = []
  226. for remote in self.__get_versions_metadata():
  227. for vehicle in remote['vehicles']:
  228. if vehicle['name'] != vehicle_name:
  229. continue
  230. for release in vehicle['releases']:
  231. versions_list.append(VersionInfo(
  232. remote=remote.get('name', None),
  233. commit_ref=release.get('commit_reference', None),
  234. release_type=release.get('release_type', None),
  235. version_number=release.get('version_number', None),
  236. ap_build_artifacts_url=release.get(
  237. 'ap_build_artifacts_url',
  238. None
  239. )
  240. ))
  241. return versions_list
  242. def get_all_vehicles_sorted_uniq(self) -> list[str]:
  243. """
  244. Return a sorted list of all vehicles listed in remotes.json structure
  245. Returns:
  246. list: Vehicles listed in remotes.json
  247. """
  248. vehicles_set = set()
  249. for remote in self.__get_versions_metadata():
  250. for vehicle in remote['vehicles']:
  251. vehicles_set.add(vehicle['name'])
  252. return sorted(list(vehicles_set))
  253. def is_version_listed(self, vehicle: str, remote: str,
  254. commit_ref: str) -> bool:
  255. """
  256. Check if a version with given properties mentioned in remotes.json
  257. Parameters:
  258. vehicle (str): vehicle for which version is listed
  259. remote (str): remote under which the version is listed
  260. commit_ref(str): commit reference for the version
  261. Returns:
  262. bool: True if the said version is mentioned in remotes.json,
  263. False otherwise
  264. """
  265. if vehicle is None:
  266. raise ValueError("Vehicle is a required parameter.")
  267. if remote is None:
  268. raise ValueError("Remote is a required parameter.")
  269. if commit_ref is None:
  270. raise ValueError("Commit reference is a required parameter.")
  271. return (remote, commit_ref) in [
  272. (version_info.remote, version_info.commit_ref)
  273. for version_info in
  274. self.get_versions_for_vehicle(vehicle_name=vehicle)
  275. ]
  276. def get_version_info(self, vehicle: str, remote: str,
  277. commit_ref: str) -> VersionInfo:
  278. """
  279. Find first version matching the given properties in remotes.json
  280. Parameters:
  281. vehicle (str): vehicle for which version is listed
  282. remote (str): remote under which the version is listed
  283. commit_ref(str): commit reference for the version
  284. Returns:
  285. VersionInfo: Object for the version matching the properties,
  286. None if not found
  287. """
  288. return next(
  289. (
  290. version
  291. for version in self.get_versions_for_vehicle(
  292. vehicle_name=vehicle
  293. )
  294. if version.remote == remote and
  295. version.commit_ref == commit_ref
  296. ),
  297. None
  298. )
  299. def reload_remotes_json(self) -> None:
  300. """
  301. Read remotes.json, validate its structure against the schema
  302. and cache it in memory
  303. """
  304. # load file containing vehicles listed to be built for each
  305. # remote along with the branches/tags/commits on which the
  306. # firmware can be built
  307. remotes_json_schema_path = os.path.join(
  308. os.path.dirname(__file__),
  309. 'remotes.schema.json'
  310. )
  311. with open(self.__remotes_json_path, 'r') as f, \
  312. open(remotes_json_schema_path, 'r') as s:
  313. f_content = f.read()
  314. # Early return if file is empty
  315. if not f_content:
  316. return
  317. versions_metadata = json.loads(f_content)
  318. schema = json.loads(s.read())
  319. # validate schema
  320. jsonschema.validate(instance=versions_metadata, schema=schema)
  321. self.__set_versions_metadata(versions_metadata=versions_metadata)
  322. # update git repo with latest remotes list
  323. self.__sync_remotes_with_ap_repo()
  324. def __ensure_remotes_json(self) -> None:
  325. """
  326. Ensures remotes.json exists and is a valid JSON file.
  327. """
  328. p = Path(self.__remotes_json_path)
  329. if not p.exists():
  330. # Ensure parent directory exists
  331. Path.mkdir(p.parent, parents=True, exist_ok=True)
  332. # write empty json list
  333. with open(p, 'w') as f:
  334. f.write('[]')
  335. def __set_versions_metadata(self, versions_metadata: list) -> None:
  336. """
  337. Set versions metadata property with the one passed as parameter
  338. This requires to acquire the access lock to avoid overwriting the
  339. object while it is being read
  340. """
  341. if versions_metadata is None:
  342. raise ValueError("versions_metadata is a required parameter. "
  343. "Cannot be None.")
  344. with self.__access_lock_versions_metadata:
  345. self.__versions_metadata = versions_metadata
  346. def __get_versions_metadata(self) -> list:
  347. """
  348. Read versions metadata property
  349. This requires to acquire the access lock to avoid reading the list
  350. while it is being modified
  351. Returns:
  352. list: the versions metadata list
  353. """
  354. with self.__access_lock_versions_metadata:
  355. return self.__versions_metadata
  356. def __sync_remotes_with_ap_repo(self):
  357. """
  358. Update the remotes in ArduPilot local repository with the latest
  359. remotes list.
  360. """
  361. remotes = tuple(
  362. (remote.name, remote.url)
  363. for remote in self.get_all_remotes_info()
  364. )
  365. self.repo.remote_add_bulk(remotes=remotes, force=True)
  366. def fetch_ap_releases(self) -> None:
  367. """
  368. Execute the fetch_releases.py script to update remotes.json
  369. with Ardupilot's official releases
  370. """
  371. from scripts import fetch_releases
  372. fetch_releases.run(
  373. base_dir=os.path.join(
  374. os.path.dirname(self.__remotes_json_path),
  375. '..',
  376. ),
  377. remote_name="ardupilot",
  378. )
  379. self.reload_remotes_json()
  380. return
  381. def fetch_whitelisted_tags(self) -> None:
  382. """
  383. Execute the fetch_whitelisted_tags.py script to update
  384. remotes.json with tags from whitelisted repos
  385. """
  386. from scripts import fetch_whitelisted_tags
  387. fetch_whitelisted_tags.run(
  388. base_dir=os.path.join(
  389. os.path.dirname(self.__remotes_json_path),
  390. '..',
  391. )
  392. )
  393. self.reload_remotes_json()
  394. return
  395. @staticmethod
  396. def get_singleton():
  397. return VersionsFetcher.__singleton