base.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232
  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
  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. async def remove_local_directory(self) -> None:
  628. """Check the local directory."""
  629. try:
  630. if self.data.category == "python_script":
  631. local_path = f"{self.content.path.local}/{self.data.name}.py"
  632. elif self.data.category == "theme":
  633. path = (
  634. f"{self.hacs.core.config_path}/"
  635. f"{self.hacs.configuration.theme_path}/"
  636. f"{self.data.name}.yaml"
  637. )
  638. if os.path.exists(path):
  639. os.remove(path)
  640. local_path = self.content.path.local
  641. elif self.data.category == "integration":
  642. if not self.data.domain:
  643. if domain := DOMAIN_OVERRIDES.get(self.data.full_name):
  644. self.data.domain = domain
  645. self.content.path.local = self.localpath
  646. else:
  647. self.logger.error("%s Missing domain", self.string)
  648. return False
  649. local_path = self.content.path.local
  650. else:
  651. local_path = self.content.path.local
  652. if os.path.exists(local_path):
  653. if not is_safe(self.hacs, local_path):
  654. self.logger.error("%s Path %s is blocked from removal", self.string, local_path)
  655. return False
  656. self.logger.debug("%s Removing %s", self.string, local_path)
  657. if self.data.category in ["python_script"]:
  658. os.remove(local_path)
  659. else:
  660. shutil.rmtree(local_path)
  661. while os.path.exists(local_path):
  662. await sleep(1)
  663. else:
  664. self.logger.debug(
  665. "%s Presumed local content path %s does not exist", self.string, local_path
  666. )
  667. except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  668. self.logger.debug("%s Removing %s failed with %s", self.string, local_path, exception)
  669. return False
  670. return True
  671. async def async_pre_registration(self) -> None:
  672. """Run pre registration steps."""
  673. @concurrent(concurrenttasks=10)
  674. async def async_registration(self, ref=None) -> None:
  675. """Run registration steps."""
  676. await self.async_pre_registration()
  677. if ref is not None:
  678. self.data.selected_tag = ref
  679. self.ref = ref
  680. self.force_branch = True
  681. if not await self.validate_repository():
  682. return False
  683. # Run common registration steps.
  684. await self.common_registration()
  685. # Set correct local path
  686. self.content.path.local = self.localpath
  687. # Run local post registration steps.
  688. await self.async_post_registration()
  689. async def async_post_registration(self) -> None:
  690. """Run post registration steps."""
  691. if not self.hacs.system.action:
  692. return
  693. await self.hacs.validation.async_run_repository_checks(self)
  694. async def async_pre_install(self) -> None:
  695. """Run pre install steps."""
  696. async def _async_pre_install(self) -> None:
  697. """Run pre install steps."""
  698. self.logger.info("%s Running pre installation steps", self.string)
  699. await self.async_pre_install()
  700. self.logger.info("%s Pre installation steps completed", self.string)
  701. async def async_install(self) -> None:
  702. """Run install steps."""
  703. await self._async_pre_install()
  704. self.hacs.async_dispatch(
  705. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  706. {"repository": self.data.full_name, "progress": 30},
  707. )
  708. self.logger.info("%s Running installation steps", self.string)
  709. await self.async_install_repository()
  710. self.hacs.async_dispatch(
  711. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  712. {"repository": self.data.full_name, "progress": 90},
  713. )
  714. self.logger.info("%s Installation steps completed", self.string)
  715. await self._async_post_install()
  716. self.hacs.async_dispatch(
  717. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  718. {"repository": self.data.full_name, "progress": False},
  719. )
  720. async def async_post_installation(self) -> None:
  721. """Run post install steps."""
  722. async def _async_post_install(self) -> None:
  723. """Run post install steps."""
  724. self.logger.info("%s Running post installation steps", self.string)
  725. await self.async_post_installation()
  726. self.data.new = False
  727. self.hacs.async_dispatch(
  728. HacsDispatchEvent.REPOSITORY,
  729. {
  730. "id": 1337,
  731. "action": "install",
  732. "repository": self.data.full_name,
  733. "repository_id": self.data.id,
  734. },
  735. )
  736. self.logger.info("%s Post installation steps completed", self.string)
  737. async def async_install_repository(self) -> None:
  738. """Common installation steps of the repository."""
  739. persistent_directory = None
  740. await self.update_repository(force=True)
  741. if self.content.path.local is None:
  742. raise HacsException("repository.content.path.local is None")
  743. self.validate.errors.clear()
  744. if not self.can_download:
  745. raise HacsException("The version of Home Assistant is not compatible with this version")
  746. version = self.version_to_download()
  747. if version == self.data.default_branch:
  748. self.ref = version
  749. else:
  750. self.ref = f"tags/{version}"
  751. self.hacs.async_dispatch(
  752. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  753. {"repository": self.data.full_name, "progress": 40},
  754. )
  755. if self.data.installed and self.data.category == "netdaemon":
  756. persistent_directory = BackupNetDaemon(hacs=self.hacs, repository=self)
  757. await self.hacs.hass.async_add_executor_job(persistent_directory.create)
  758. elif self.repository_manifest.persistent_directory:
  759. if os.path.exists(
  760. f"{self.content.path.local}/{self.repository_manifest.persistent_directory}"
  761. ):
  762. persistent_directory = Backup(
  763. hacs=self.hacs,
  764. local_path=f"{self.content.path.local}/{self.repository_manifest.persistent_directory}",
  765. backup_path=tempfile.gettempdir() + "/hacs_persistent_directory/",
  766. )
  767. await self.hacs.hass.async_add_executor_job(persistent_directory.create)
  768. if self.data.installed and not self.content.single:
  769. backup = Backup(hacs=self.hacs, local_path=self.content.path.local)
  770. await self.hacs.hass.async_add_executor_job(backup.create)
  771. self.hacs.log.debug("%s Local path is set to %s", self.string, self.content.path.local)
  772. self.hacs.log.debug("%s Remote path is set to %s", self.string, self.content.path.remote)
  773. self.hacs.async_dispatch(
  774. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  775. {"repository": self.data.full_name, "progress": 50},
  776. )
  777. if self.repository_manifest.zip_release and version != self.data.default_branch:
  778. await self.download_zip_files(self.validate)
  779. else:
  780. await self.download_content()
  781. self.hacs.async_dispatch(
  782. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  783. {"repository": self.data.full_name, "progress": 70},
  784. )
  785. if self.validate.errors:
  786. for error in self.validate.errors:
  787. self.logger.error("%s %s", self.string, error)
  788. if self.data.installed and not self.content.single:
  789. await self.hacs.hass.async_add_executor_job(backup.restore)
  790. await self.hacs.hass.async_add_executor_job(backup.cleanup)
  791. raise HacsException("Could not download, see log for details")
  792. self.hacs.async_dispatch(
  793. HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
  794. {"repository": self.data.full_name, "progress": 80},
  795. )
  796. if self.data.installed and not self.content.single:
  797. await self.hacs.hass.async_add_executor_job(backup.cleanup)
  798. if persistent_directory is not None:
  799. await self.hacs.hass.async_add_executor_job(persistent_directory.restore)
  800. await self.hacs.hass.async_add_executor_job(persistent_directory.cleanup)
  801. if self.validate.success:
  802. self.data.installed = True
  803. self.data.installed_commit = self.data.last_commit
  804. if version == self.data.default_branch:
  805. self.data.installed_version = None
  806. else:
  807. self.data.installed_version = version
  808. async def async_get_legacy_repository_object(
  809. self,
  810. etag: str | None = None,
  811. ) -> tuple[AIOGitHubAPIRepository, Any | None]:
  812. """Return a repository object."""
  813. try:
  814. repository = await self.hacs.github.get_repo(self.data.full_name, etag)
  815. return repository, self.hacs.github.client.last_response.etag
  816. except AIOGitHubAPINotModifiedException as exception:
  817. raise HacsNotModifiedException(exception) from exception
  818. except (ValueError, AIOGitHubAPIException, Exception) as exception:
  819. raise HacsException(exception) from exception
  820. def update_filenames(self) -> None:
  821. """Get the filename to target."""
  822. async def get_tree(self, ref: str):
  823. """Return the repository tree."""
  824. if self.repository_object is None:
  825. raise HacsException("No repository_object")
  826. try:
  827. tree = await self.repository_object.get_tree(ref)
  828. return tree
  829. except (ValueError, AIOGitHubAPIException) as exception:
  830. raise HacsException(exception) from exception
  831. async def get_releases(self, prerelease=False, returnlimit=5) -> list[GitHubReleaseModel]:
  832. """Return the repository releases."""
  833. response = await self.hacs.async_github_api_method(
  834. method=self.hacs.githubapi.repos.releases.list,
  835. repository=self.data.full_name,
  836. )
  837. releases = []
  838. for release in response.data or []:
  839. if len(releases) == returnlimit:
  840. break
  841. if release.draft or (release.prerelease and not prerelease):
  842. continue
  843. releases.append(release)
  844. return releases
  845. async def common_update_data(
  846. self,
  847. ignore_issues: bool = False,
  848. force: bool = False,
  849. retry=False,
  850. ) -> None:
  851. """Common update data."""
  852. releases = []
  853. try:
  854. repository_object, etag = await self.async_get_legacy_repository_object(
  855. etag=None if force or self.data.installed else self.data.etag_repository,
  856. )
  857. self.repository_object = repository_object
  858. if self.data.full_name.lower() != repository_object.full_name.lower():
  859. self.hacs.common.renamed_repositories[
  860. self.data.full_name
  861. ] = repository_object.full_name
  862. raise HacsRepositoryExistException
  863. self.data.update_data(
  864. repository_object.attributes,
  865. action=self.hacs.system.action,
  866. )
  867. self.data.etag_repository = etag
  868. except HacsNotModifiedException:
  869. return
  870. except HacsRepositoryExistException:
  871. raise HacsRepositoryExistException from None
  872. except (AIOGitHubAPIException, HacsException) as exception:
  873. if not self.hacs.status.startup:
  874. self.logger.error("%s %s", self.string, exception)
  875. if not ignore_issues:
  876. self.validate.errors.append("Repository does not exist.")
  877. raise HacsException(exception) from exception
  878. # Make sure the repository is not archived.
  879. if self.data.archived and not ignore_issues:
  880. self.validate.errors.append("Repository is archived.")
  881. if self.data.full_name not in self.hacs.common.archived_repositories:
  882. self.hacs.common.archived_repositories.append(self.data.full_name)
  883. raise HacsRepositoryArchivedException(f"{self} Repository is archived.")
  884. # Make sure the repository is not in the blacklist.
  885. if self.hacs.repositories.is_removed(self.data.full_name):
  886. removed = self.hacs.repositories.removed_repository(self.data.full_name)
  887. if removed.removal_type != "remove" and not ignore_issues:
  888. self.validate.errors.append("Repository has been requested to be removed.")
  889. raise HacsException(f"{self} Repository has been requested to be removed.")
  890. # Get releases.
  891. try:
  892. releases = await self.get_releases(
  893. prerelease=self.data.show_beta,
  894. returnlimit=self.hacs.configuration.release_limit,
  895. )
  896. if releases:
  897. self.data.releases = True
  898. self.releases.objects = releases
  899. self.data.published_tags = [x.tag_name for x in self.releases.objects]
  900. self.data.last_version = next(iter(self.data.published_tags))
  901. except HacsException:
  902. self.data.releases = False
  903. if not self.force_branch:
  904. self.ref = self.version_to_download()
  905. if self.data.releases:
  906. for release in self.releases.objects or []:
  907. if release.tag_name == self.ref:
  908. if assets := release.assets:
  909. downloads = next(iter(assets)).download_count
  910. self.data.downloads = downloads
  911. self.hacs.log.debug(
  912. "%s Running checks against %s", self.string, self.ref.replace("tags/", "")
  913. )
  914. try:
  915. self.tree = await self.get_tree(self.ref)
  916. if not self.tree:
  917. raise HacsException("No files in tree")
  918. self.treefiles = []
  919. for treefile in self.tree:
  920. self.treefiles.append(treefile.full_path)
  921. except (AIOGitHubAPIException, HacsException) as exception:
  922. if (
  923. not retry
  924. and self.ref is not None
  925. and str(exception).startswith("GitHub returned 404")
  926. ):
  927. # Handle tags/branches being deleted.
  928. self.data.selected_tag = None
  929. self.ref = self.version_to_download()
  930. self.logger.warning(
  931. "%s Selected version/branch %s has been removed, falling back to default",
  932. self.string,
  933. self.ref,
  934. )
  935. return await self.common_update_data(ignore_issues, force, True)
  936. if not self.hacs.status.startup and not ignore_issues:
  937. self.logger.error("%s %s", self.string, exception)
  938. if not ignore_issues:
  939. raise HacsException(exception) from None
  940. def gather_files_to_download(self) -> list[FileInformation]:
  941. """Return a list of file objects to be downloaded."""
  942. files = []
  943. tree = self.tree
  944. ref = f"{self.ref}".replace("tags/", "")
  945. releaseobjects = self.releases.objects
  946. category = self.data.category
  947. remotelocation = self.content.path.remote
  948. if self.should_try_releases:
  949. for release in releaseobjects or []:
  950. if ref == release.tag_name:
  951. for asset in release.assets or []:
  952. files.append(
  953. FileInformation(asset.browser_download_url, asset.name, asset.name)
  954. )
  955. if files:
  956. return files
  957. if self.content.single:
  958. for treefile in tree:
  959. if treefile.filename == self.data.file_name:
  960. files.append(
  961. FileInformation(
  962. treefile.download_url, treefile.full_path, treefile.filename
  963. )
  964. )
  965. return files
  966. if category == "plugin":
  967. for treefile in tree:
  968. if treefile.path in ["", "dist"]:
  969. if remotelocation == "dist" and not treefile.filename.startswith("dist"):
  970. continue
  971. if not remotelocation:
  972. if not treefile.filename.endswith(".js"):
  973. continue
  974. if treefile.path != "":
  975. continue
  976. if not treefile.is_directory:
  977. files.append(
  978. FileInformation(
  979. treefile.download_url, treefile.full_path, treefile.filename
  980. )
  981. )
  982. if files:
  983. return files
  984. if self.repository_manifest.content_in_root:
  985. if not self.repository_manifest.filename:
  986. if category == "theme":
  987. tree = filter_content_return_one_of_type(self.tree, "", "yaml", "full_path")
  988. for path in tree:
  989. if path.is_directory:
  990. continue
  991. if path.full_path.startswith(self.content.path.remote):
  992. files.append(FileInformation(path.download_url, path.full_path, path.filename))
  993. return files
  994. @concurrent(concurrenttasks=10)
  995. async def dowload_repository_content(self, content: FileInformation) -> None:
  996. """Download content."""
  997. try:
  998. self.logger.debug("%s Downloading %s", self.string, content.name)
  999. filecontent = await self.hacs.async_download_file(content.download_url)
  1000. if filecontent is None:
  1001. self.validate.errors.append(f"[{content.name}] was not downloaded.")
  1002. return
  1003. # Save the content of the file.
  1004. if self.content.single or content.path is None:
  1005. local_directory = self.content.path.local
  1006. else:
  1007. _content_path = content.path
  1008. if not self.repository_manifest.content_in_root:
  1009. _content_path = _content_path.replace(f"{self.content.path.remote}", "")
  1010. local_directory = f"{self.content.path.local}/{_content_path}"
  1011. local_directory = local_directory.split("/")
  1012. del local_directory[-1]
  1013. local_directory = "/".join(local_directory)
  1014. # Check local directory
  1015. pathlib.Path(local_directory).mkdir(parents=True, exist_ok=True)
  1016. local_file_path = (f"{local_directory}/{content.name}").replace("//", "/")
  1017. result = await self.hacs.async_save_file(local_file_path, filecontent)
  1018. if result:
  1019. self.logger.info("%s Download of %s completed", self.string, content.name)
  1020. return
  1021. self.validate.errors.append(f"[{content.name}] was not downloaded.")
  1022. except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
  1023. self.validate.errors.append(f"Download was not completed [{exception}]")
  1024. async def async_remove_entity_device(self) -> None:
  1025. """Remove the entity device."""
  1026. if (
  1027. self.hacs.configuration == ConfigurationType.YAML
  1028. or not self.hacs.configuration.experimental
  1029. ):
  1030. return
  1031. device_registry: dr.DeviceRegistry = dr.async_get(hass=self.hacs.hass)
  1032. device = device_registry.async_get_device(identifiers={(DOMAIN, str(self.data.id))})
  1033. if device is None:
  1034. return
  1035. device_registry.async_remove_device(device_id=device.id)
  1036. def version_to_download(self) -> str:
  1037. """Determine which version to download."""
  1038. if self.data.last_version is not None:
  1039. if self.data.selected_tag is not None:
  1040. if self.data.selected_tag == self.data.last_version:
  1041. self.data.selected_tag = None
  1042. return self.data.last_version
  1043. return self.data.selected_tag
  1044. return self.data.last_version
  1045. if self.data.selected_tag is not None:
  1046. if self.data.selected_tag == self.data.default_branch:
  1047. return self.data.default_branch
  1048. if self.data.selected_tag in self.data.published_tags:
  1049. return self.data.selected_tag
  1050. return self.data.default_branch or "main"