Home Assistant Unofficial Reference 2024.12.1
importer.py
Go to the documentation of this file.
1 """Import logic for blueprint."""
2 
3 from __future__ import annotations
4 
5 from contextlib import suppress
6 from dataclasses import dataclass
7 import html
8 import re
9 
10 import voluptuous as vol
11 import yarl
12 
13 from homeassistant.core import HomeAssistant
14 from homeassistant.exceptions import HomeAssistantError
15 from homeassistant.helpers import aiohttp_client, config_validation as cv
16 from homeassistant.util import yaml
17 
18 from .models import Blueprint
19 from .schemas import BLUEPRINT_SCHEMA, is_blueprint_config
20 
21 COMMUNITY_TOPIC_PATTERN = re.compile(
22  r"^https://community.home-assistant.io/t/[a-z0-9-]+/(?P<topic>\d+)(?:/(?P<post>\d+)|)$"
23 )
24 
25 COMMUNITY_CODE_BLOCK = re.compile(
26  r'<code class="lang-(?P<syntax>[a-z]+)">(?P<content>(?:.|\n)*)</code>', re.MULTILINE
27 )
28 
29 GITHUB_FILE_PATTERN = re.compile(
30  r"^https://github.com/(?P<repository>.+)/blob/(?P<path>.+)$"
31 )
32 
33 WEBSITE_PATTERN = re.compile(
34  r"^https://(?P<subdomain>[a-z0-9-]+)\.home-assistant\.io/(?P<path>.+).yaml$"
35 )
36 
37 COMMUNITY_TOPIC_SCHEMA = vol.Schema(
38  {
39  "slug": str,
40  "title": str,
41  "post_stream": {"posts": [{"updated_at": cv.datetime, "cooked": str}]},
42  },
43  extra=vol.ALLOW_EXTRA,
44 )
45 
46 
48  """When the function doesn't support the url."""
49 
50 
51 @dataclass(frozen=True)
53  """Imported blueprint."""
54 
55  suggested_filename: str
56  raw_data: str
57  blueprint: Blueprint
58 
59 
60 def _get_github_import_url(url: str) -> str:
61  """Convert a GitHub url to the raw content.
62 
63  Async friendly.
64  """
65  if url.startswith("https://raw.githubusercontent.com/"):
66  return url
67 
68  if (match := GITHUB_FILE_PATTERN.match(url)) is None:
69  raise UnsupportedUrl("Not a GitHub file url")
70 
71  repo, path = match.groups()
72 
73  return f"https://raw.githubusercontent.com/{repo}/{path}"
74 
75 
76 def _get_community_post_import_url(url: str) -> str:
77  """Convert a forum post url to an import url.
78 
79  Async friendly.
80  """
81  if (match := COMMUNITY_TOPIC_PATTERN.match(url)) is None:
82  raise UnsupportedUrl("Not a topic url")
83 
84  _topic, post = match.groups()
85 
86  json_url = url
87 
88  if post is not None:
89  # Chop off post part, ie /2
90  json_url = json_url[: -len(post) - 1]
91 
92  json_url += ".json"
93 
94  return json_url
95 
96 
98  url: str,
99  topic: dict,
100 ) -> ImportedBlueprint:
101  """Extract a blueprint from a community post JSON.
102 
103  Async friendly.
104  """
105  block_content: str
106  blueprint = None
107  post = topic["post_stream"]["posts"][0]
108 
109  for match in COMMUNITY_CODE_BLOCK.finditer(post["cooked"]):
110  block_syntax, block_content = match.groups()
111 
112  if block_syntax not in ("auto", "yaml"):
113  continue
114 
115  block_content = html.unescape(block_content.strip())
116 
117  try:
118  data = yaml.parse_yaml(block_content)
119  except HomeAssistantError:
120  if block_syntax == "yaml":
121  raise
122 
123  continue
124 
125  if not is_blueprint_config(data):
126  continue
127  assert isinstance(data, dict)
128 
129  blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
130  break
131 
132  if blueprint is None:
133  raise HomeAssistantError(
134  "No valid blueprint found in the topic. Blueprint syntax blocks need to be"
135  " marked as YAML or no syntax."
136  )
137 
138  return ImportedBlueprint(
139  f'{post["username"]}/{topic["slug"]}', block_content, blueprint
140  )
141 
142 
144  hass: HomeAssistant, url: str
145 ) -> ImportedBlueprint:
146  """Get blueprints from a community post url.
147 
148  Method can raise aiohttp client exceptions, vol.Invalid.
149 
150  Caller needs to implement own timeout.
151  """
152  import_url = _get_community_post_import_url(url)
153  session = aiohttp_client.async_get_clientsession(hass)
154 
155  resp = await session.get(import_url, raise_for_status=True)
156  json_resp = await resp.json()
157  json_resp = COMMUNITY_TOPIC_SCHEMA(json_resp)
158  return _extract_blueprint_from_community_topic(url, json_resp)
159 
160 
162  hass: HomeAssistant, url: str
163 ) -> ImportedBlueprint:
164  """Get a blueprint from a github url."""
165  import_url = _get_github_import_url(url)
166  session = aiohttp_client.async_get_clientsession(hass)
167 
168  resp = await session.get(import_url, raise_for_status=True)
169  raw_yaml = await resp.text()
170  data = yaml.parse_yaml(raw_yaml)
171  assert isinstance(data, dict)
172  blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
173 
174  parsed_import_url = yarl.URL(import_url)
175  suggested_filename = f"{parsed_import_url.parts[1]}/{parsed_import_url.parts[-1]}"
176  if suggested_filename.endswith(".yaml"):
177  suggested_filename = suggested_filename[:-5]
178 
179  return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
180 
181 
183  hass: HomeAssistant, url: str
184 ) -> ImportedBlueprint:
185  """Get a blueprint from a Github Gist."""
186  if not url.startswith("https://gist.github.com/"):
187  raise UnsupportedUrl("Not a GitHub gist url")
188 
189  parsed_url = yarl.URL(url)
190  session = aiohttp_client.async_get_clientsession(hass)
191 
192  resp = await session.get(
193  f"https://api.github.com/gists/{parsed_url.parts[2]}",
194  headers={"Accept": "application/vnd.github.v3+json"},
195  raise_for_status=True,
196  )
197  gist = await resp.json()
198 
199  blueprint = None
200  filename = None
201  content: str
202 
203  for filename, info in gist["files"].items():
204  if not filename.endswith(".yaml"):
205  continue
206 
207  content = info["content"]
208  data = yaml.parse_yaml(content)
209 
210  if not is_blueprint_config(data):
211  continue
212  assert isinstance(data, dict)
213 
214  blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
215  break
216 
217  if blueprint is None:
218  raise HomeAssistantError(
219  "No valid blueprint found in the gist. The blueprint file needs to end with"
220  " '.yaml'"
221  )
222 
223  return ImportedBlueprint(
224  f"{gist['owner']['login']}/{filename[:-5]}", content, blueprint
225  )
226 
227 
229  hass: HomeAssistant, url: str
230 ) -> ImportedBlueprint:
231  """Get a blueprint from our website."""
232  if (WEBSITE_PATTERN.match(url)) is None:
233  raise UnsupportedUrl("Not a Home Assistant website URL")
234 
235  session = aiohttp_client.async_get_clientsession(hass)
236 
237  resp = await session.get(url, raise_for_status=True)
238  raw_yaml = await resp.text()
239  data = yaml.parse_yaml(raw_yaml)
240  assert isinstance(data, dict)
241  blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
242 
243  parsed_import_url = yarl.URL(url)
244  suggested_filename = f"homeassistant/{parsed_import_url.parts[-1][:-5]}"
245  return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
246 
247 
249  hass: HomeAssistant, url: str
250 ) -> ImportedBlueprint:
251  """Get a blueprint from a generic website."""
252  session = aiohttp_client.async_get_clientsession(hass)
253 
254  resp = await session.get(url, raise_for_status=True)
255  raw_yaml = await resp.text()
256  data = yaml.parse_yaml(raw_yaml)
257 
258  assert isinstance(data, dict)
259  blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
260 
261  parsed_import_url = yarl.URL(url)
262  suggested_filename = f"{parsed_import_url.host}/{parsed_import_url.parts[-1][:-5]}"
263  return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
264 
265 
266 FETCH_FUNCTIONS = (
267  fetch_blueprint_from_community_post,
268  fetch_blueprint_from_github_url,
269  fetch_blueprint_from_github_gist_url,
270  fetch_blueprint_from_website_url,
271  fetch_blueprint_from_generic_url,
272 )
273 
274 
275 async def fetch_blueprint_from_url(hass: HomeAssistant, url: str) -> ImportedBlueprint:
276  """Get a blueprint from a url.
277 
278  The returned blueprint will only be validated with BLUEPRINT_SCHEMA, not the domain
279  specific schema.
280  """
281  for func in FETCH_FUNCTIONS:
282  with suppress(UnsupportedUrl):
283  imported_bp = await func(hass, url)
284  imported_bp.blueprint.update_metadata(source_url=url)
285  return imported_bp
286 
287  raise HomeAssistantError("Unsupported URL")
ImportedBlueprint fetch_blueprint_from_website_url(HomeAssistant hass, str url)
Definition: importer.py:230
ImportedBlueprint fetch_blueprint_from_community_post(HomeAssistant hass, str url)
Definition: importer.py:145
ImportedBlueprint fetch_blueprint_from_url(HomeAssistant hass, str url)
Definition: importer.py:275
ImportedBlueprint _extract_blueprint_from_community_topic(str url, dict topic)
Definition: importer.py:100
ImportedBlueprint fetch_blueprint_from_github_url(HomeAssistant hass, str url)
Definition: importer.py:163
ImportedBlueprint fetch_blueprint_from_github_gist_url(HomeAssistant hass, str url)
Definition: importer.py:184
ImportedBlueprint fetch_blueprint_from_generic_url(HomeAssistant hass, str url)
Definition: importer.py:250