base.py 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233
  1. """Repository."""
  2. from __future__ import annotations
  3. from asyncio import sleep
  4. from datetime import datetime
  5. import os
  6. import pathlib
  7. import shutil
  8. import tempfile
  9. from typing import TYPE_CHECKING, Any
  10. import zipfile
  11. from aiogithubapi import (
  12. AIOGitHubAPIException,
  13. AIOGitHubAPINotModifiedException,
  14. GitHubReleaseModel,
  15. )
  16. from aiogithubapi.const import BASE_API_URL
  17. from aiogithubapi.objects.repository import AIOGitHubAPIRepository
  18. import attr
  19. from homeassistant.helpers import device_registry as dr, issue_registry as ir
  20. from ..const import DOMAIN
  21. from ..enums import ConfigurationType, HacsDispatchEvent, RepositoryFile
  22. from ..exceptions import (
  23. HacsException,
  24. HacsNotModifiedException,
  25. HacsRepositoryArchivedException,
  26. HacsRepositoryExistException,
  27. )
  28. from ..utils.backup import Backup, BackupNetDaemon
  29. from ..utils.decode import decode_content
  30. from ..utils.decorator import concurrent
  31. from ..utils.filters import filter_content_return_one_of_type
  32. from ..utils.json import json_loads
  33. from ..utils.logger import LOGGER
  34. from ..utils.path import is_safe
  35. from ..utils.queue_manager import QueueManager
  36. from ..utils.store import async_remove_store
  37. from ..utils.template import render_template
  38. from ..utils.validate import Validate
  39. from ..utils.version import (
  40. version_left_higher_or_equal_then_right,
  41. version_left_higher_then_right,
  42. )
  43. from ..utils.workarounds import DOMAIN_OVERRIDES
  44. if TYPE_CHECKING:
  45. from ..base import HacsBase
  46. TOPIC_FILTER = (
  47. "custom-card",
  48. "custom-component",
  49. "custom-components",
  50. "customcomponents",
  51. "hacktoberfest",
  52. "hacs-default",
  53. "hacs-integration",
  54. "hacs",
  55. "hass",
  56. "hassio",
  57. "home-assistant",
  58. "home-automation",
  59. "homeassistant-components",
  60. "homeassistant-integration",
  61. "homeassistant-sensor",
  62. "homeassistant",
  63. "homeautomation",
  64. "integration",
  65. "lovelace",
  66. "python",
  67. "sensor",
  68. "theme",
  69. "themes",
  70. "custom-cards",
  71. "home-assistant-frontend",
  72. "home-assistant-hacs",
  73. "home-assistant-custom",
  74. "lovelace-ui",
  75. )
  76. class FileInformation:
  77. """FileInformation."""
  78. def __init__(self, url, path, name):
  79. self.download_url = url
  80. self.path = path
  81. self.name = name
  82. @attr.s(auto_attribs=True)
  83. class RepositoryData:
  84. """RepositoryData class."""
  85. archived: bool = False
  86. authors: list[str] = []
  87. category: str = ""
  88. config_flow: bool = False
  89. default_branch: str = None
  90. description: str = ""
  91. domain: str = None
  92. downloads: int = 0
  93. etag_repository: str = None
  94. file_name: str = ""
  95. first_install: bool = False
  96. full_name: str = ""
  97. hide: bool = False
  98. has_issues: bool = True
  99. id: int = 0
  100. installed_commit: str = None
  101. installed_version: str = None
  102. installed: bool = False
  103. last_commit: str = None
  104. last_fetched: datetime = None
  105. last_updated: str = 0
  106. last_version: str = None
  107. manifest_name: str = None
  108. new: bool = True
  109. open_issues: int = 0
  110. published_tags: list[str] = []
  111. pushed_at: str = ""
  112. releases: bool = False
  113. selected_tag: str = None
  114. show_beta: bool = False
  115. stargazers_count: int = 0
  116. topics: list[str] = []
  117. @property
  118. def name(self):
  119. """Return the name."""
  120. if self.category in ["integration", "netdaemon"]:
  121. return self.domain
  122. return self.full_name.split("/")[-1]
  123. def to_json(self):
  124. """Export to json."""
  125. return attr.asdict(self, filter=lambda attr, value: attr.name != "last_fetched")
  126. @staticmethod
  127. def create_from_dict(source: dict, action: bool = False) -> RepositoryData:
  128. """Set attributes from dicts."""
  129. data = RepositoryData()
  130. data.update_data(source, action)
  131. return data
  132. def update_data(self, data: dict, action: bool = False) -> None:
  133. """Update data of the repository."""
  134. for key in data:
  135. if key not in self.__dict__:
  136. continue
  137. if key == "pushed_at":
  138. if data[key] == "":
  139. continue
  140. if "Z" in data[key]:
  141. setattr(
  142. self,
  143. key,
  144. datetime.strptime(data[key], "%Y-%m-%dT%H:%M:%SZ"),
  145. )
  146. else:
  147. setattr(self, key, datetime.strptime(data[key], "%Y-%m-%dT%H:%M:%S"))
  148. elif key == "id":
  149. setattr(self, key, str(data[key]))
  150. elif key == "country":
  151. if isinstance(data[key], str):
  152. setattr(self, key, [data[key]])
  153. else:
  154. setattr(self, key, data[key])
  155. elif key == "topics" and not action:
  156. setattr(self, key, [topic for topic in data[key] if topic not in TOPIC_FILTER])
  157. else:
  158. setattr(self, key, data[key])
  159. @attr.s(auto_attribs=True)
  160. class HacsManifest:
  161. """HacsManifest class."""
  162. content_in_root: bool = False
  163. country: list[str] = []
  164. filename: str = None
  165. hacs: str = None # Minimum HACS version
  166. hide_default_branch: bool = False
  167. homeassistant: str = None # Minimum Home Assistant version
  168. manifest: dict = {}
  169. name: str = None
  170. persistent_directory: str = None
  171. render_readme: bool = False
  172. zip_release: bool = False
  173. def to_dict(self):
  174. """Export to json."""
  175. return attr.asdict(self)
  176. @staticmethod
  177. def from_dict(manifest: dict):
  178. """Set attributes from dicts."""
  179. if manifest is None:
  180. raise HacsException("Missing manifest data")
  181. manifest_data = HacsManifest()
  182. manifest_data.manifest = {
  183. k: v
  184. for k, v in manifest.items()
  185. if k in manifest_data.__dict__ and v != manifest_data.__getattribute__(k)
  186. }
  187. for key, value in manifest_data.manifest.items():
  188. if key == "country" and isinstance(value, str):
  189. setattr(manifest_data, key, [value])
  190. elif key in manifest_data.__dict__:
  191. setattr(manifest_data, key, value)
  192. return manifest_data
  193. class RepositoryReleases:
  194. """RepositoyReleases."""
  195. last_release = None
  196. last_release_object = None
  197. published_tags = []
  198. objects: list[GitHubReleaseModel] = []
  199. releases = False
  200. downloads = None
  201. class RepositoryPath:
  202. """RepositoryPath."""
  203. local: str | None = None
  204. remote: str | None = None
  205. class RepositoryContent:
  206. """RepositoryContent."""
  207. path: RepositoryPath | None = None
  208. files = []
  209. objects = []
  210. single = False
  211. class HacsRepository:
  212. """HacsRepository."""
  213. def __init__(self, hacs: HacsBase) -> None:
  214. """Set up HacsRepository."""
  215. self.hacs = hacs
  216. self.additional_info = ""
  217. self.data = RepositoryData()
  218. self.content = RepositoryContent()
  219. self.content.path = RepositoryPath()
  220. self.repository_object: AIOGitHubAPIRepository | None = None
  221. self.updated_info = False
  222. self.state = None
  223. self.force_branch = False
  224. self.integration_manifest = {}
  225. self.repository_manifest = HacsManifest.from_dict({})
  226. self.validate = Validate()
  227. self.releases = RepositoryReleases()
  228. self.pending_restart = False
  229. self.tree = []
  230. self.treefiles = []
  231. self.ref = None
  232. self.logger = LOGGER
  233. def __str__(self) -> str:
  234. """Return a string representation of the repository."""
  235. return self.string
  236. @property
  237. def string(self) -> str:
  238. """Return a string representation of the repository."""
  239. return f"<{self.data.category.title()} {self.data.full_name}>"
  240. @property
  241. def display_name(self) -> str:
  242. """Return display name."""
  243. if self.repository_manifest.name is not None:
  244. return self.repository_manifest.name
  245. if self.data.category == "integration":
  246. if self.data.manifest_name is not None:
  247. return self.data.manifest_name
  248. if "name" in self.integration_manifest:
  249. return self.integration_manifest["name"]
  250. return self.data.full_name.split("/")[-1].replace("-", " ").replace("_", " ").title()
  251. @property
  252. def ignored_by_country_configuration(self) -> bool:
  253. """Return True if hidden by country."""
  254. if self.data.installed:
  255. return False
  256. configuration = self.hacs.configuration.country.lower()
  257. if configuration == "all":
  258. return False
  259. manifest = [entry.lower() for entry in self.repository_manifest.country or []]
  260. if not manifest:
  261. return False
  262. return configuration not in manifest
  263. @property
  264. def display_status(self) -> str:
  265. """Return display_status."""
  266. if self.data.new:
  267. status = "new"
  268. elif self.pending_restart:
  269. status = "pending-restart"
  270. elif self.pending_update:
  271. status = "pending-upgrade"
  272. elif self.data.installed:
  273. status = "installed"
  274. else:
  275. status = "default"
  276. return status
  277. @property
  278. def display_installed_version(self) -> str:
  279. """Return display_authors"""
  280. if self.data.installed_version is not None:
  281. installed = self.data.installed_version
  282. else:
  283. if self.data.installed_commit is not None:
  284. installed = self.data.installed_commit
  285. else:
  286. installed = ""
  287. return str(installed)
  288. @property
  289. def display_available_version(self) -> str:
  290. """Return display_authors"""
  291. if self.data.last_version is not None:
  292. available = self.data.last_version
  293. else:
  294. if self.data.last_commit is not None:
  295. available = self.data.last_commit
  296. else:
  297. available = ""
  298. return str(available)
  299. @property
  300. def display_version_or_commit(self) -> str:
  301. """Does the repositoriy use releases or commits?"""
  302. if self.data.releases:
  303. version_or_commit = "version"
  304. else:
  305. version_or_commit = "commit"
  306. return version_or_commit
  307. @property
  308. def pending_update(self) -> bool:
  309. """Return True if pending update."""
  310. if not self.can_download:
  311. return False
  312. if self.data.installed:
  313. if self.data.selected_tag is not None:
  314. if self.data.selected_tag == self.data.default_branch:
  315. if self.data.installed_commit != self.data.last_commit:
  316. return True
  317. return False
  318. if self.display_version_or_commit == "version":
  319. if (
  320. result := version_left_higher_then_right(
  321. self.display_available_version,
  322. self.display_installed_version,
  323. )
  324. ) is not None:
  325. return result
  326. if self.display_installed_version != self.display_available_version:
  327. return True
  328. return False
  329. @property
  330. def can_download(self) -> bool:
  331. """Return True if we can download."""
  332. if self.repository_manifest.homeassistant is not None:
  333. if self.data.releases:
  334. if not version_left_higher_or_equal_then_right(
  335. self.hacs.core.ha_version.string,
  336. self.repository_manifest.homeassistant,
  337. ):
  338. return False
  339. return True
  340. @property
  341. def localpath(self) -> str | None:
  342. """Return localpath."""
  343. return None
  344. @property
  345. def should_try_releases(self) -> bool:
  346. """Return a boolean indicating whether to download releases or not."""
  347. if self.repository_manifest.zip_release:
  348. if self.repository_manifest.filename.endswith(".zip"):
  349. if self.ref != self.data.default_branch:
  350. return True
  351. if self.ref == self.data.default_branch:
  352. return False
  353. if self.data.category not in ["plugin", "theme"]:
  354. return False
  355. if not self.data.releases:
  356. return False
  357. return True
  358. async def validate_repository(self) -> None:
  359. """Validate."""
  360. @concurrent(concurrenttasks=10, backoff_time=5)
  361. async def update_repository(self, ignore_issues=False, force=False) -> None:
  362. """Update the repository"""
  363. async def common_validate(self, ignore_issues: bool = False) -> None:
  364. """Common validation steps of the repository."""
  365. self.validate.errors.clear()
  366. # Make sure the repository exist.
  367. self.logger.debug("%s Checking repository.", self.string)
  368. await self.common_update_data(ignore_issues=ignore_issues)
  369. # Get the content of hacs.json
  370. if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]:
  371. if manifest := await self.async_get_hacs_json():
  372. self.repository_manifest = HacsManifest.from_dict(manifest)
  373. self.data.update_data(
  374. self.repository_manifest.to_dict(),
  375. action=self.hacs.system.action,
  376. )
  377. async def common_registration(self) -> None:
  378. """Common registration steps of the repository."""
  379. # Attach repository
  380. if self.repository_object is None:
  381. try:
  382. self.repository_object, etag = await self.async_get_legacy_repository_object(
  383. etag=None if self.data.installed else self.data.etag_repository,
  384. )
  385. self.data.update_data(
  386. self.repository_object.attributes,
  387. action=self.hacs.system.action,
  388. )
  389. self.data.etag_repository = etag
  390. except HacsNotModifiedException:
  391. self.logger.debug("%s Did not update, content was not modified", self.string)
  392. return
  393. # Set topics
  394. self.data.topics = self.data.topics
  395. # Set description
  396. self.data.description = self.data.description
  397. @concurrent(concurrenttasks=10, backoff_time=5)
  398. async def common_update(self, ignore_issues=False, force=False) -> bool:
  399. """Common information update steps of the repository."""
  400. self.logger.debug("%s Getting repository information", self.string)
  401. # Attach repository
  402. current_etag = self.data.etag_repository
  403. try:
  404. await self.common_update_data(ignore_issues=ignore_issues, force=force)
  405. except HacsRepositoryExistException:
  406. self.data.full_name = self.hacs.common.renamed_repositories[self.data.full_name]
  407. await self.common_update_data(ignore_issues=ignore_issues, force=force)
  408. except HacsException:
  409. if not ignore_issues and not force:
  410. return False
  411. if not self.data.installed and (current_etag == self.data.etag_repository) and not force:
  412. self.logger.debug("%s Did not update, content was not modified", self.string)
  413. return False
  414. # Update last updated
  415. if self.repository_object:
  416. self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0)
  417. # Update last available commit
  418. await self.repository_object.set_last_commit()
  419. self.data.last_commit = self.repository_object.last_commit
  420. # Get the content of hacs.json
  421. if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]:
  422. if manifest := await self.async_get_hacs_json():
  423. self.repository_manifest = HacsManifest.from_dict(manifest)
  424. self.data.update_data(
  425. self.repository_manifest.to_dict(),
  426. action=self.hacs.system.action,
  427. )
  428. # Update "info.md"
  429. self.additional_info = await self.async_get_info_file_contents()
  430. # Set last fetch attribute
  431. self.data.last_fetched = datetime.now()
  432. return True
  433. async def download_zip_files(self, validate) -> None:
  434. """Download ZIP archive from repository release."""
  435. try:
  436. contents = None
  437. target_ref = self.ref.split("/")[1]
  438. for release in self.releases.objects:
  439. self.logger.debug(
  440. "%s ref: %s --- tag: %s", self.string, target_ref, release.tag_name
  441. )
  442. if release.tag_name == target_ref:
  443. contents = release.assets
  444. break
  445. if not contents:
  446. validate.errors.append(f"No assets found for release '{self.ref}'")
  447. return
  448. download_queue = QueueManager(hass=self.hacs.hass)
  449. for content in contents or []:
  450. download_queue.add(self.async_download_zip_file(content, validate))
  451. await download_queue.execute()
  452. except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  453. validate.errors.append("Download was not completed")
  454. async def async_download_zip_file(self, content, validate) -> None:
  455. """Download ZIP archive from repository release."""
  456. try:
  457. filecontent = await self.hacs.async_download_file(content.browser_download_url)
  458. if filecontent is None:
  459. validate.errors.append(f"[{content.name}] was not downloaded")
  460. return
  461. temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp)
  462. temp_file = f"{temp_dir}/{self.repository_manifest.filename}"
  463. result = await self.hacs.async_save_file(temp_file, filecontent)
  464. with zipfile.ZipFile(temp_file, "r") as zip_file:
  465. zip_file.extractall(self.content.path.local)
  466. def cleanup_temp_dir():
  467. """Cleanup temp_dir."""
  468. if os.path.exists(temp_dir):
  469. self.logger.debug("%s Cleaning up %s", self.string, temp_dir)
  470. shutil.rmtree(temp_dir)
  471. if result:
  472. self.logger.info("%s Download of %s completed", self.string, content.name)
  473. await self.hacs.hass.async_add_executor_job(cleanup_temp_dir)
  474. return
  475. validate.errors.append(f"[{content.name}] was not downloaded")
  476. except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  477. validate.errors.append("Download was not completed")
  478. async def download_content(self) -> None:
  479. """Download the content of a directory."""
  480. if self.hacs.configuration.experimental:
  481. if (
  482. not self.repository_manifest.zip_release
  483. and not self.data.file_name
  484. and self.content.path.remote is not None
  485. ):
  486. self.logger.info("%s Trying experimental download", self.string)
  487. try:
  488. await self.download_repository_zip()
  489. return
  490. except HacsException as exception:
  491. self.logger.exception(exception)
  492. contents = self.gather_files_to_download()
  493. if self.repository_manifest.filename:
  494. self.logger.debug("%s %s", self.string, self.repository_manifest.filename)
  495. if not contents:
  496. raise HacsException("No content to download")
  497. download_queue = QueueManager(hass=self.hacs.hass)
  498. for content in contents:
  499. if self.repository_manifest.content_in_root and self.repository_manifest.filename:
  500. if content.name != self.repository_manifest.filename:
  501. continue
  502. download_queue.add(self.dowload_repository_content(content))
  503. await download_queue.execute()
  504. async def download_repository_zip(self):
  505. """Download the zip archive of the repository."""
  506. ref = f"{self.ref}".replace("tags/", "")
  507. if not ref:
  508. raise HacsException("Missing required elements.")
  509. url = f"{BASE_API_URL}/repos/{self.data.full_name}/zipball/{ref}"
  510. filecontent = await self.hacs.async_download_file(
  511. url,
  512. headers={
  513. "Authorization": f"token {self.hacs.configuration.token}",
  514. "User-Agent": f"HACS/{self.hacs.version}",
  515. },
  516. )
  517. if filecontent is None:
  518. raise HacsException(f"[{self}] Failed to download zipball")
  519. temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp)
  520. temp_file = f"{temp_dir}/{self.repository_manifest.filename}"
  521. result = await self.hacs.async_save_file(temp_file, filecontent)
  522. if not result:
  523. raise HacsException("Could not save ZIP file")
  524. with zipfile.ZipFile(temp_file, "r") as zip_file:
  525. extractable = []
  526. for path in zip_file.filelist:
  527. filename = "/".join(path.filename.split("/")[1:])
  528. if (
  529. filename.startswith(self.content.path.remote)
  530. and filename != self.content.path.remote
  531. ):
  532. path.filename = filename.replace(self.content.path.remote, "")
  533. extractable.append(path)
  534. zip_file.extractall(self.content.path.local, extractable)
  535. def cleanup_temp_dir():
  536. """Cleanup temp_dir."""
  537. if os.path.exists(temp_dir):
  538. self.logger.debug("%s Cleaning up %s", self.string, temp_dir)
  539. shutil.rmtree(temp_dir)
  540. await self.hacs.hass.async_add_executor_job(cleanup_temp_dir)
  541. self.logger.info("%s Content was extracted to %s", self.string, self.content.path.local)
  542. async def async_get_hacs_json(self, ref: str = None) -> dict[str, Any] | None:
  543. """Get the content of the hacs.json file."""
  544. try:
  545. response = await self.hacs.async_github_api_method(
  546. method=self.hacs.githubapi.repos.contents.get,
  547. raise_exception=False,
  548. repository=self.data.full_name,
  549. path=RepositoryFile.HACS_JSON,
  550. **{"params": {"ref": ref or self.version_to_download()}},
  551. )
  552. if response:
  553. return json_loads(decode_content(response.data.content))
  554. except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  555. pass
  556. async def async_get_info_file_contents(self) -> str:
  557. """Get the content of the info.md file."""
  558. def _info_file_variants() -> tuple[str, ...]:
  559. name: str = (
  560. "readme"
  561. if self.repository_manifest.render_readme or self.hacs.configuration.experimental
  562. else "info"
  563. )
  564. return (
  565. f"{name.upper()}.md",
  566. f"{name}.md",
  567. f"{name}.MD",
  568. f"{name.upper()}.MD",
  569. name.upper(),
  570. name,
  571. )
  572. info_files = [filename for filename in _info_file_variants() if filename in self.treefiles]
  573. if not info_files:
  574. return ""
  575. try:
  576. response = await self.hacs.async_github_api_method(
  577. method=self.hacs.githubapi.repos.contents.get,
  578. raise_exception=False,
  579. repository=self.data.full_name,
  580. path=info_files[0],
  581. )
  582. if response:
  583. return render_template(
  584. self.hacs,
  585. decode_content(response.data.content)
  586. .replace("<svg", "<disabled")
  587. .replace("</svg", "</disabled"),
  588. self,
  589. )
  590. except BaseException as exc: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  591. self.logger.error("%s %s", self.string, exc)
  592. return ""
  593. def remove(self) -> None:
  594. """Run remove tasks."""
  595. self.logger.info("%s Starting removal", self.string)
  596. if self.hacs.repositories.is_registered(repository_id=str(self.data.id)):
  597. self.hacs.repositories.unregister(self)
  598. async def uninstall(self) -> None:
  599. """Run uninstall tasks."""
  600. self.logger.info("%s Removing", self.string)
  601. if not await self.remove_local_directory():
  602. raise HacsException("Could not uninstall")
  603. self.data.installed = False
  604. if self.data.category == "integration":
  605. if self.data.config_flow:
  606. await self.reload_custom_components()
  607. else:
  608. self.pending_restart = True
  609. elif self.data.category == "theme":
  610. try:
  611. await self.hacs.hass.services.async_call("frontend", "reload_themes", {})
  612. except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  613. pass
  614. await async_remove_store(self.hacs.hass, f"hacs/{self.data.id}.hacs")
  615. self.data.installed_version = None
  616. self.data.installed_commit = None
  617. self.hacs.async_dispatch(
  618. HacsDispatchEvent.REPOSITORY,
  619. {
  620. "id": 1337,
  621. "action": "uninstall",
  622. "repository": self.data.full_name,
  623. "repository_id": self.data.id,
  624. },
  625. )
  626. await self.async_remove_entity_device()
  627. ir.async_delete_issue(self.hacs.hass, DOMAIN, f"removed_{self.data.id}")
  628. async def remove_local_directory(self) -> None:
  629. """Check the local directory."""
  630. try:
  631. if self.data.category == "python_script":
  632. local_path = f"{self.content.path.local}/{self.data.name}.py"
  633. elif self.data.category == "theme":
  634. path = (
  635. f"{self.hacs.core.config_path}/"
  636. f"{self.hacs.configuration.theme_path}/"
  637. f"{self.data.name}.yaml"
  638. )
  639. if os.path.exists(path):
  640. os.remove(path)
  641. local_path = self.content.path.local
  642. elif self.data.category == "integration":
  643. if not self.data.domain:
  644. if domain := DOMAIN_OVERRIDES.get(self.data.full_name):
  645. self.data.domain = domain
  646. self.content.path.local = self.localpath
  647. else:
  648. self.logger.error("%s Missing domain", self.string)
  649. return False
  650. local_path = self.content.path.local
  651. else:
  652. local_path = self.content.path.local
  653. if os.path.exists(local_path):
  654. if not is_safe(self.hacs, local_path):
  655. self.logger.error("%s Path %s is blocked from removal", self.string, local_path)
  656. return False
  657. self.logger.debug("%s Removing %s", self.string, local_path)
  658. if self.data.category in ["python_script"]:
  659. os.remove(local_path)
  660. else:
  661. shutil.rmtree(local_path)
  662. while os.path.exists(local_path):
  663. await sleep(1)
  664. else:
  665. self.logger.debug(
  666. "%s Presumed local content path %s does not exist", self.string, local_path
  667. )
  668. except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  669. self.logger.debug("%s Removing %s failed with %s", self.string, local_path, exception)
  670. return False
  671. return True
  672. async def async_pre_registration(self) -> None:
  673. """Run pre registration steps."""
  674. @concurrent(concurrenttasks=10)
  675. async def async_registration(self, ref=None) -> None:
  676. """Run registration steps."""
  677. await self.async_pre_registration()
  678. if ref is not None:
  679. self.data.selected_tag = ref
  680. self.ref = ref
  681. self.force_branch = True
  682. if not await self.validate_repository():
  683. return False
  684. # Run common registration steps.
  685. await self.common_registration()
  686. # Set correct local path
  687. self.content.path.local = self.localpath
  688. # Run local post registration steps.
  689. await self.async_post_registration()
  690. async def async_post_registration(self) -> None:
  691. """Run post registration steps."""
  692. if not self.hacs.system.action:
  693. return
  694. await self.hacs.validation.async_run_repository_checks(self)
  695. async def async_pre_install(self) -> None:
  696. """Run pre install steps."""
  697. async def _async_pre_install(self) -> None:
  698. """Run pre install steps."""
  699. self.logger.info("%s Running pre installation steps", self.string)
  700. await self.async_pre_install()
  701. self.logger.info("%s Pre installation steps completed", self.string)
  702. async def async_install(self) -> None:
  703. """Run install steps."""
  704. await self._async_pre_install()
  705. self.hacs.async_dispatch(
  706. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  707. {"repository": self.data.full_name, "progress": 30},
  708. )
  709. self.logger.info("%s Running installation steps", self.string)
  710. await self.async_install_repository()
  711. self.hacs.async_dispatch(
  712. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  713. {"repository": self.data.full_name, "progress": 90},
  714. )
  715. self.logger.info("%s Installation steps completed", self.string)
  716. await self._async_post_install()
  717. self.hacs.async_dispatch(
  718. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  719. {"repository": self.data.full_name, "progress": False},
  720. )
  721. async def async_post_installation(self) -> None:
  722. """Run post install steps."""
  723. async def _async_post_install(self) -> None:
  724. """Run post install steps."""
  725. self.logger.info("%s Running post installation steps", self.string)
  726. await self.async_post_installation()
  727. self.data.new = False
  728. self.hacs.async_dispatch(
  729. HacsDispatchEvent.REPOSITORY,
  730. {
  731. "id": 1337,
  732. "action": "install",
  733. "repository": self.data.full_name,
  734. "repository_id": self.data.id,
  735. },
  736. )
  737. self.logger.info("%s Post installation steps completed", self.string)
  738. async def async_install_repository(self) -> None:
  739. """Common installation steps of the repository."""
  740. persistent_directory = None
  741. await self.update_repository(force=True)
  742. if self.content.path.local is None:
  743. raise HacsException("repository.content.path.local is None")
  744. self.validate.errors.clear()
  745. if not self.can_download:
  746. raise HacsException("The version of Home Assistant is not compatible with this version")
  747. version = self.version_to_download()
  748. if version == self.data.default_branch:
  749. self.ref = version
  750. else:
  751. self.ref = f"tags/{version}"
  752. self.hacs.async_dispatch(
  753. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  754. {"repository": self.data.full_name, "progress": 40},
  755. )
  756. if self.data.installed and self.data.category == "netdaemon":
  757. persistent_directory = BackupNetDaemon(hacs=self.hacs, repository=self)
  758. await self.hacs.hass.async_add_executor_job(persistent_directory.create)
  759. elif self.repository_manifest.persistent_directory:
  760. if os.path.exists(
  761. f"{self.content.path.local}/{self.repository_manifest.persistent_directory}"
  762. ):
  763. persistent_directory = Backup(
  764. hacs=self.hacs,
  765. local_path=f"{self.content.path.local}/{self.repository_manifest.persistent_directory}",
  766. backup_path=tempfile.gettempdir() + "/hacs_persistent_directory/",
  767. )
  768. await self.hacs.hass.async_add_executor_job(persistent_directory.create)
  769. if self.data.installed and not self.content.single:
  770. backup = Backup(hacs=self.hacs, local_path=self.content.path.local)
  771. await self.hacs.hass.async_add_executor_job(backup.create)
  772. self.hacs.log.debug("%s Local path is set to %s", self.string, self.content.path.local)
  773. self.hacs.log.debug("%s Remote path is set to %s", self.string, self.content.path.remote)
  774. self.hacs.async_dispatch(
  775. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  776. {"repository": self.data.full_name, "progress": 50},
  777. )
  778. if self.repository_manifest.zip_release and version != self.data.default_branch:
  779. await self.download_zip_files(self.validate)
  780. else:
  781. await self.download_content()
  782. self.hacs.async_dispatch(
  783. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  784. {"repository": self.data.full_name, "progress": 70},
  785. )
  786. if self.validate.errors:
  787. for error in self.validate.errors:
  788. self.logger.error("%s %s", self.string, error)
  789. if self.data.installed and not self.content.single:
  790. await self.hacs.hass.async_add_executor_job(backup.restore)
  791. await self.hacs.hass.async_add_executor_job(backup.cleanup)
  792. raise HacsException("Could not download, see log for details")
  793. self.hacs.async_dispatch(
  794. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  795. {"repository": self.data.full_name, "progress": 80},
  796. )
  797. if self.data.installed and not self.content.single:
  798. await self.hacs.hass.async_add_executor_job(backup.cleanup)
  799. if persistent_directory is not None:
  800. await self.hacs.hass.async_add_executor_job(persistent_directory.restore)
  801. await self.hacs.hass.async_add_executor_job(persistent_directory.cleanup)
  802. if self.validate.success:
  803. self.data.installed = True
  804. self.data.installed_commit = self.data.last_commit
  805. if version == self.data.default_branch:
  806. self.data.installed_version = None
  807. else:
  808. self.data.installed_version = version
  809. async def async_get_legacy_repository_object(
  810. self,
  811. etag: str | None = None,
  812. ) -> tuple[AIOGitHubAPIRepository, Any | None]:
  813. """Return a repository object."""
  814. try:
  815. repository = await self.hacs.github.get_repo(self.data.full_name, etag)
  816. return repository, self.hacs.github.client.last_response.etag
  817. except AIOGitHubAPINotModifiedException as exception:
  818. raise HacsNotModifiedException(exception) from exception
  819. except (ValueError, AIOGitHubAPIException, Exception) as exception:
  820. raise HacsException(exception) from exception
  821. def update_filenames(self) -> None:
  822. """Get the filename to target."""
  823. async def get_tree(self, ref: str):
  824. """Return the repository tree."""
  825. if self.repository_object is None:
  826. raise HacsException("No repository_object")
  827. try:
  828. tree = await self.repository_object.get_tree(ref)
  829. return tree
  830. except (ValueError, AIOGitHubAPIException) as exception:
  831. raise HacsException(exception) from exception
  832. async def get_releases(self, prerelease=False, returnlimit=5) -> list[GitHubReleaseModel]:
  833. """Return the repository releases."""
  834. response = await self.hacs.async_github_api_method(
  835. method=self.hacs.githubapi.repos.releases.list,
  836. repository=self.data.full_name,
  837. )
  838. releases = []
  839. for release in response.data or []:
  840. if len(releases) == returnlimit:
  841. break
  842. if release.draft or (release.prerelease and not prerelease):
  843. continue
  844. releases.append(release)
  845. return releases
  846. async def common_update_data(
  847. self,
  848. ignore_issues: bool = False,
  849. force: bool = False,
  850. retry=False,
  851. ) -> None:
  852. """Common update data."""
  853. releases = []
  854. try:
  855. repository_object, etag = await self.async_get_legacy_repository_object(
  856. etag=None if force or self.data.installed else self.data.etag_repository,
  857. )
  858. self.repository_object = repository_object
  859. if self.data.full_name.lower() != repository_object.full_name.lower():
  860. self.hacs.common.renamed_repositories[
  861. self.data.full_name
  862. ] = repository_object.full_name
  863. raise HacsRepositoryExistException
  864. self.data.update_data(
  865. repository_object.attributes,
  866. action=self.hacs.system.action,
  867. )
  868. self.data.etag_repository = etag
  869. except HacsNotModifiedException:
  870. return
  871. except HacsRepositoryExistException:
  872. raise HacsRepositoryExistException from None
  873. except (AIOGitHubAPIException, HacsException) as exception:
  874. if not self.hacs.status.startup:
  875. self.logger.error("%s %s", self.string, exception)
  876. if not ignore_issues:
  877. self.validate.errors.append("Repository does not exist.")
  878. raise HacsException(exception) from exception
  879. # Make sure the repository is not archived.
  880. if self.data.archived and not ignore_issues:
  881. self.validate.errors.append("Repository is archived.")
  882. if self.data.full_name not in self.hacs.common.archived_repositories:
  883. self.hacs.common.archived_repositories.append(self.data.full_name)
  884. raise HacsRepositoryArchivedException(f"{self} Repository is archived.")
  885. # Make sure the repository is not in the blacklist.
  886. if self.hacs.repositories.is_removed(self.data.full_name):
  887. removed = self.hacs.repositories.removed_repository(self.data.full_name)
  888. if removed.removal_type != "remove" and not ignore_issues:
  889. self.validate.errors.append("Repository has been requested to be removed.")
  890. raise HacsException(f"{self} Repository has been requested to be removed.")
  891. # Get releases.
  892. try:
  893. releases = await self.get_releases(
  894. prerelease=self.data.show_beta,
  895. returnlimit=self.hacs.configuration.release_limit,
  896. )
  897. if releases:
  898. self.data.releases = True
  899. self.releases.objects = releases
  900. self.data.published_tags = [x.tag_name for x in self.releases.objects]
  901. self.data.last_version = next(iter(self.data.published_tags))
  902. except HacsException:
  903. self.data.releases = False
  904. if not self.force_branch:
  905. self.ref = self.version_to_download()
  906. if self.data.releases:
  907. for release in self.releases.objects or []:
  908. if release.tag_name == self.ref:
  909. if assets := release.assets:
  910. downloads = next(iter(assets)).download_count
  911. self.data.downloads = downloads
  912. self.hacs.log.debug(
  913. "%s Running checks against %s", self.string, self.ref.replace("tags/", "")
  914. )
  915. try:
  916. self.tree = await self.get_tree(self.ref)
  917. if not self.tree:
  918. raise HacsException("No files in tree")
  919. self.treefiles = []
  920. for treefile in self.tree:
  921. self.treefiles.append(treefile.full_path)
  922. except (AIOGitHubAPIException, HacsException) as exception:
  923. if (
  924. not retry
  925. and self.ref is not None
  926. and str(exception).startswith("GitHub returned 404")
  927. ):
  928. # Handle tags/branches being deleted.
  929. self.data.selected_tag = None
  930. self.ref = self.version_to_download()
  931. self.logger.warning(
  932. "%s Selected version/branch %s has been removed, falling back to default",
  933. self.string,
  934. self.ref,
  935. )
  936. return await self.common_update_data(ignore_issues, force, True)
  937. if not self.hacs.status.startup and not ignore_issues:
  938. self.logger.error("%s %s", self.string, exception)
  939. if not ignore_issues:
  940. raise HacsException(exception) from None
  941. def gather_files_to_download(self) -> list[FileInformation]:
  942. """Return a list of file objects to be downloaded."""
  943. files = []
  944. tree = self.tree
  945. ref = f"{self.ref}".replace("tags/", "")
  946. releaseobjects = self.releases.objects
  947. category = self.data.category
  948. remotelocation = self.content.path.remote
  949. if self.should_try_releases:
  950. for release in releaseobjects or []:
  951. if ref == release.tag_name:
  952. for asset in release.assets or []:
  953. files.append(
  954. FileInformation(asset.browser_download_url, asset.name, asset.name)
  955. )
  956. if files:
  957. return files
  958. if self.content.single:
  959. for treefile in tree:
  960. if treefile.filename == self.data.file_name:
  961. files.append(
  962. FileInformation(
  963. treefile.download_url, treefile.full_path, treefile.filename
  964. )
  965. )
  966. return files
  967. if category == "plugin":
  968. for treefile in tree:
  969. if treefile.path in ["", "dist"]:
  970. if remotelocation == "dist" and not treefile.filename.startswith("dist"):
  971. continue
  972. if not remotelocation:
  973. if not treefile.filename.endswith(".js"):
  974. continue
  975. if treefile.path != "":
  976. continue
  977. if not treefile.is_directory:
  978. files.append(
  979. FileInformation(
  980. treefile.download_url, treefile.full_path, treefile.filename
  981. )
  982. )
  983. if files:
  984. return files
  985. if self.repository_manifest.content_in_root:
  986. if not self.repository_manifest.filename:
  987. if category == "theme":
  988. tree = filter_content_return_one_of_type(self.tree, "", "yaml", "full_path")
  989. for path in tree:
  990. if path.is_directory:
  991. continue
  992. if path.full_path.startswith(self.content.path.remote):
  993. files.append(FileInformation(path.download_url, path.full_path, path.filename))
  994. return files
  995. @concurrent(concurrenttasks=10)
  996. async def dowload_repository_content(self, content: FileInformation) -> None:
  997. """Download content."""
  998. try:
  999. self.logger.debug("%s Downloading %s", self.string, content.name)
  1000. filecontent = await self.hacs.async_download_file(content.download_url)
  1001. if filecontent is None:
  1002. self.validate.errors.append(f"[{content.name}] was not downloaded.")
  1003. return
  1004. # Save the content of the file.
  1005. if self.content.single or content.path is None:
  1006. local_directory = self.content.path.local
  1007. else:
  1008. _content_path = content.path
  1009. if not self.repository_manifest.content_in_root:
  1010. _content_path = _content_path.replace(f"{self.content.path.remote}", "")
  1011. local_directory = f"{self.content.path.local}/{_content_path}"
  1012. local_directory = local_directory.split("/")
  1013. del local_directory[-1]
  1014. local_directory = "/".join(local_directory)
  1015. # Check local directory
  1016. pathlib.Path(local_directory).mkdir(parents=True, exist_ok=True)
  1017. local_file_path = (f"{local_directory}/{content.name}").replace("//", "/")
  1018. result = await self.hacs.async_save_file(local_file_path, filecontent)
  1019. if result:
  1020. self.logger.info("%s Download of %s completed", self.string, content.name)
  1021. return
  1022. self.validate.errors.append(f"[{content.name}] was not downloaded.")
  1023. except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  1024. self.validate.errors.append(f"Download was not completed [{exception}]")
  1025. async def async_remove_entity_device(self) -> None:
  1026. """Remove the entity device."""
  1027. if (
  1028. self.hacs.configuration == ConfigurationType.YAML
  1029. or not self.hacs.configuration.experimental
  1030. ):
  1031. return
  1032. device_registry: dr.DeviceRegistry = dr.async_get(hass=self.hacs.hass)
  1033. device = device_registry.async_get_device(identifiers={(DOMAIN, str(self.data.id))})
  1034. if device is None:
  1035. return
  1036. device_registry.async_remove_device(device_id=device.id)
  1037. def version_to_download(self) -> str:
  1038. """Determine which version to download."""
  1039. if self.data.last_version is not None:
  1040. if self.data.selected_tag is not None:
  1041. if self.data.selected_tag == self.data.last_version:
  1042. self.data.selected_tag = None
  1043. return self.data.last_version
  1044. return self.data.selected_tag
  1045. return self.data.last_version
  1046. if self.data.selected_tag is not None:
  1047. if self.data.selected_tag == self.data.default_branch:
  1048. return self.data.default_branch
  1049. if self.data.selected_tag in self.data.published_tags:
  1050. return self.data.selected_tag
  1051. return self.data.default_branch or "main"