Home Assistant Unofficial Reference 2024.12.1
models.py
Go to the documentation of this file.
1 """Blueprint models."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Awaitable, Callable
7 import logging
8 import pathlib
9 import shutil
10 from typing import Any
11 
12 from awesomeversion import AwesomeVersion
13 import voluptuous as vol
14 from voluptuous.humanize import humanize_error
15 
16 from homeassistant import loader
17 from homeassistant.const import (
18  CONF_DEFAULT,
19  CONF_DOMAIN,
20  CONF_NAME,
21  CONF_PATH,
22  __version__,
23 )
24 from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
25 from homeassistant.exceptions import HomeAssistantError
26 from homeassistant.util import yaml
27 
28 from .const import (
29  BLUEPRINT_FOLDER,
30  CONF_BLUEPRINT,
31  CONF_HOMEASSISTANT,
32  CONF_INPUT,
33  CONF_MIN_VERSION,
34  CONF_SOURCE_URL,
35  CONF_USE_BLUEPRINT,
36  DOMAIN,
37 )
38 from .errors import (
39  BlueprintException,
40  BlueprintInUse,
41  FailedToLoad,
42  FileAlreadyExists,
43  InvalidBlueprint,
44  InvalidBlueprintInputs,
45  MissingInput,
46 )
47 from .schemas import BLUEPRINT_INSTANCE_FIELDS
48 
49 
50 class Blueprint:
51  """Blueprint of a configuration structure."""
52 
53  def __init__(
54  self,
55  data: dict[str, Any],
56  *,
57  path: str | None = None,
58  expected_domain: str | None = None,
59  schema: Callable[[Any], Any],
60  ) -> None:
61  """Initialize a blueprint."""
62  try:
63  data = self.datadata = schema(data)
64  except vol.Invalid as err:
65  raise InvalidBlueprint(expected_domain, path, data, err) from err
66 
67  # In future, we will treat this as "incorrect" and allow to recover from this
68  data_domain = data[CONF_BLUEPRINT][CONF_DOMAIN]
69  if expected_domain is not None and data_domain != expected_domain:
70  raise InvalidBlueprint(
71  expected_domain,
72  path or self.namename,
73  data,
74  (
75  f"Found incorrect blueprint type {data_domain}, expected"
76  f" {expected_domain}"
77  ),
78  )
79 
80  self.domaindomain = data_domain
81 
82  missing = yaml.extract_inputs(data) - set(self.inputsinputs)
83 
84  if missing:
85  raise InvalidBlueprint(
86  data_domain,
87  path or self.namename,
88  data,
89  f"Missing input definition for {', '.join(missing)}",
90  )
91 
92  @property
93  def name(self) -> str:
94  """Return blueprint name."""
95  return self.datadata[CONF_BLUEPRINT][CONF_NAME] # type: ignore[no-any-return]
96 
97  @property
98  def inputs(self) -> dict[str, Any]:
99  """Return flattened blueprint inputs."""
100  inputs = {}
101  for key, value in self.datadata[CONF_BLUEPRINT][CONF_INPUT].items():
102  if value and CONF_INPUT in value:
103  inputs.update(dict(value[CONF_INPUT]))
104  else:
105  inputs[key] = value
106  return inputs
107 
108  @property
109  def metadata(self) -> dict[str, Any]:
110  """Return blueprint metadata."""
111  return self.datadata[CONF_BLUEPRINT] # type: ignore[no-any-return]
112 
113  def update_metadata(self, *, source_url: str | None = None) -> None:
114  """Update metadata."""
115  if source_url is not None:
116  self.datadata[CONF_BLUEPRINT][CONF_SOURCE_URL] = source_url
117 
118  def yaml(self) -> str:
119  """Dump blueprint as YAML."""
120  return yaml.dump(self.datadata)
121 
122  @callback
123  def validate(self) -> list[str] | None:
124  """Test if the Home Assistant installation supports this blueprint.
125 
126  Return list of errors if not valid.
127  """
128  errors = []
129  metadata = self.metadatametadata
130  min_version = metadata.get(CONF_HOMEASSISTANT, {}).get(CONF_MIN_VERSION)
131 
132  if min_version is not None and AwesomeVersion(__version__) < AwesomeVersion(
133  min_version
134  ):
135  errors.append(f"Requires at least Home Assistant {min_version}")
136 
137  return errors or None
138 
139 
141  """Inputs for a blueprint."""
142 
143  def __init__(
144  self, blueprint: Blueprint, config_with_inputs: dict[str, Any]
145  ) -> None:
146  """Instantiate a blueprint inputs object."""
147  self.blueprintblueprint = blueprint
148  self.config_with_inputsconfig_with_inputs = config_with_inputs
149 
150  @property
151  def inputs(self) -> dict[str, Any]:
152  """Return the inputs."""
153  return self.config_with_inputsconfig_with_inputs[CONF_USE_BLUEPRINT][CONF_INPUT] # type: ignore[no-any-return]
154 
155  @property
156  def inputs_with_default(self) -> dict[str, Any]:
157  """Return the inputs and fallback to defaults."""
158  no_input = set(self.blueprintblueprint.inputs) - set(self.inputsinputs)
159 
160  inputs_with_default = dict(self.inputsinputs)
161 
162  for inp in no_input:
163  blueprint_input = self.blueprintblueprint.inputs[inp]
164  if isinstance(blueprint_input, dict) and CONF_DEFAULT in blueprint_input:
165  inputs_with_default[inp] = blueprint_input[CONF_DEFAULT]
166 
167  return inputs_with_default
168 
169  def validate(self) -> None:
170  """Validate the inputs."""
171  missing = set(self.blueprintblueprint.inputs) - set(self.inputs_with_defaultinputs_with_default)
172 
173  if missing:
174  raise MissingInput(self.blueprintblueprint.domain, self.blueprintblueprint.name, missing)
175 
176  # In future we can see if entities are correct domain, areas exist etc
177  # using the new selector helper.
178 
179  @callback
180  def async_substitute(self) -> dict:
181  """Get the blueprint value with the inputs substituted."""
182  processed = yaml.substitute(self.blueprintblueprint.data, self.inputs_with_defaultinputs_with_default)
183  combined = {**processed, **self.config_with_inputsconfig_with_inputs}
184  # From config_with_inputs
185  combined.pop(CONF_USE_BLUEPRINT)
186  # From blueprint
187  combined.pop(CONF_BLUEPRINT)
188  return combined
189 
190 
192  """Blueprints for a specific domain."""
193 
194  def __init__(
195  self,
196  hass: HomeAssistant,
197  domain: str,
198  logger: logging.Logger,
199  blueprint_in_use: Callable[[HomeAssistant, str], bool],
200  reload_blueprint_consumers: Callable[[HomeAssistant, str], Awaitable[None]],
201  blueprint_schema: Callable[[Any], Any],
202  ) -> None:
203  """Initialize a domain blueprints instance."""
204  self.hasshass = hass
205  self.domaindomain = domain
206  self.loggerlogger = logger
207  self._blueprint_in_use_blueprint_in_use = blueprint_in_use
208  self._reload_blueprint_consumers_reload_blueprint_consumers = reload_blueprint_consumers
209  self._blueprints_blueprints: dict[str, Blueprint | None] = {}
210  self._load_lock_load_lock = asyncio.Lock()
211  self._blueprint_schema_blueprint_schema = blueprint_schema
212 
213  hass.data.setdefault(DOMAIN, {})[domain] = self
214 
215  @property
216  def blueprint_folder(self) -> pathlib.Path:
217  """Return the blueprint folder."""
218  return pathlib.Path(self.hasshass.config.path(BLUEPRINT_FOLDER, self.domaindomain))
219 
220  async def async_reset_cache(self) -> None:
221  """Reset the blueprint cache."""
222  async with self._load_lock_load_lock:
223  self._blueprints_blueprints = {}
224 
225  def _load_blueprint(self, blueprint_path: str) -> Blueprint:
226  """Load a blueprint."""
227  try:
228  blueprint_data = yaml.load_yaml_dict(self.blueprint_folderblueprint_folder / blueprint_path)
229  except FileNotFoundError as err:
230  raise FailedToLoad(
231  self.domaindomain,
232  blueprint_path,
233  FileNotFoundError(f"Unable to find {blueprint_path}"),
234  ) from err
235  except HomeAssistantError as err:
236  raise FailedToLoad(self.domaindomain, blueprint_path, err) from err
237 
238  return Blueprint(
239  blueprint_data,
240  expected_domain=self.domaindomain,
241  path=blueprint_path,
242  schema=self._blueprint_schema_blueprint_schema,
243  )
244 
245  def _load_blueprints(self) -> dict[str, Blueprint | BlueprintException | None]:
246  """Load all the blueprints."""
247  blueprint_folder = pathlib.Path(
248  self.hasshass.config.path(BLUEPRINT_FOLDER, self.domaindomain)
249  )
250  results: dict[str, Blueprint | BlueprintException | None] = {}
251 
252  for path in blueprint_folder.glob("**/*.yaml"):
253  blueprint_path = str(path.relative_to(blueprint_folder))
254  if self._blueprints_blueprints.get(blueprint_path) is None:
255  try:
256  self._blueprints_blueprints[blueprint_path] = self._load_blueprint_load_blueprint(
257  blueprint_path
258  )
259  except BlueprintException as err:
260  self._blueprints_blueprints[blueprint_path] = None
261  results[blueprint_path] = err
262  continue
263 
264  results[blueprint_path] = self._blueprints_blueprints[blueprint_path]
265 
266  return results
267 
269  self,
270  ) -> dict[str, Blueprint | BlueprintException | None]:
271  """Get all the blueprints."""
272  async with self._load_lock_load_lock:
273  return await self.hasshass.async_add_executor_job(self._load_blueprints_load_blueprints)
274 
275  async def async_get_blueprint(self, blueprint_path: str) -> Blueprint:
276  """Get a blueprint."""
277 
278  def load_from_cache() -> Blueprint:
279  """Load blueprint from cache."""
280  if (blueprint := self._blueprints_blueprints[blueprint_path]) is None:
281  raise FailedToLoad(
282  self.domaindomain,
283  blueprint_path,
284  FileNotFoundError(f"Unable to find {blueprint_path}"),
285  )
286  return blueprint
287 
288  if blueprint_path in self._blueprints_blueprints:
289  return load_from_cache()
290 
291  async with self._load_lock_load_lock:
292  # Check it again
293  if blueprint_path in self._blueprints_blueprints:
294  return load_from_cache()
295 
296  try:
297  blueprint = await self.hasshass.async_add_executor_job(
298  self._load_blueprint_load_blueprint, blueprint_path
299  )
300  except FailedToLoad:
301  self._blueprints_blueprints[blueprint_path] = None
302  raise
303 
304  self._blueprints_blueprints[blueprint_path] = blueprint
305  return blueprint
306 
308  self, config_with_blueprint: dict
309  ) -> BlueprintInputs:
310  """Process a blueprint config."""
311  try:
312  config_with_blueprint = BLUEPRINT_INSTANCE_FIELDS(config_with_blueprint)
313  except vol.Invalid as err:
315  self.domaindomain, humanize_error(config_with_blueprint, err)
316  ) from err
317 
318  bp_conf = config_with_blueprint[CONF_USE_BLUEPRINT]
319  blueprint = await self.async_get_blueprintasync_get_blueprint(bp_conf[CONF_PATH])
320  inputs = BlueprintInputs(blueprint, config_with_blueprint)
321  inputs.validate()
322  return inputs
323 
324  async def async_remove_blueprint(self, blueprint_path: str) -> None:
325  """Remove a blueprint file."""
326  if self._blueprint_in_use_blueprint_in_use(self.hasshass, blueprint_path):
327  raise BlueprintInUse(self.domaindomain, blueprint_path)
328  path = self.blueprint_folderblueprint_folder / blueprint_path
329  await self.hasshass.async_add_executor_job(path.unlink)
330  self._blueprints_blueprints[blueprint_path] = None
331 
333  self, blueprint: Blueprint, blueprint_path: str, allow_override: bool
334  ) -> bool:
335  """Create blueprint file.
336 
337  Returns true if the action overrides an existing blueprint.
338  """
339 
340  path = pathlib.Path(
341  self.hasshass.config.path(BLUEPRINT_FOLDER, self.domaindomain, blueprint_path)
342  )
343  exists = path.exists()
344 
345  if not allow_override and exists:
346  raise FileAlreadyExists(self.domaindomain, blueprint_path)
347 
348  path.parent.mkdir(parents=True, exist_ok=True)
349  path.write_text(blueprint.yaml(), encoding="utf-8")
350  return exists
351 
353  self, blueprint: Blueprint, blueprint_path: str, allow_override: bool = False
354  ) -> bool:
355  """Add a blueprint."""
356  overrides_existing = await self.hasshass.async_add_executor_job(
357  self._create_file_create_file, blueprint, blueprint_path, allow_override
358  )
359 
360  self._blueprints_blueprints[blueprint_path] = blueprint
361 
362  if overrides_existing:
363  await self._reload_blueprint_consumers_reload_blueprint_consumers(self.hasshass, blueprint_path)
364 
365  return overrides_existing
366 
367  async def async_populate(self) -> None:
368  """Create folder if it doesn't exist and populate with examples."""
369  if self._blueprints_blueprints:
370  # If we have already loaded some blueprint the blueprint folder must exist
371  return
372 
373  integration = await loader.async_get_integration(self.hasshass, self.domaindomain)
374 
375  def populate() -> None:
376  if self.blueprint_folderblueprint_folder.exists():
377  return
378 
379  shutil.copytree(
380  integration.file_path / BLUEPRINT_FOLDER,
381  self.blueprint_folderblueprint_folder / HOMEASSISTANT_DOMAIN,
382  )
383 
384  await self.hasshass.async_add_executor_job(populate)
None __init__(self, Blueprint blueprint, dict[str, Any] config_with_inputs)
Definition: models.py:145
None __init__(self, dict[str, Any] data, *str|None path=None, str|None expected_domain=None, Callable[[Any], Any] schema)
Definition: models.py:60
None update_metadata(self, *str|None source_url=None)
Definition: models.py:113
Blueprint async_get_blueprint(self, str blueprint_path)
Definition: models.py:275
None __init__(self, HomeAssistant hass, str domain, logging.Logger logger, Callable[[HomeAssistant, str], bool] blueprint_in_use, Callable[[HomeAssistant, str], Awaitable[None]] reload_blueprint_consumers, Callable[[Any], Any] blueprint_schema)
Definition: models.py:202
bool async_add_blueprint(self, Blueprint blueprint, str blueprint_path, bool allow_override=False)
Definition: models.py:354
dict[str, Blueprint|BlueprintException|None] async_get_blueprints(self)
Definition: models.py:270
BlueprintInputs async_inputs_from_config(self, dict config_with_blueprint)
Definition: models.py:309
None async_remove_blueprint(self, str blueprint_path)
Definition: models.py:324
Blueprint _load_blueprint(self, str blueprint_path)
Definition: models.py:225
dict[str, Blueprint|BlueprintException|None] _load_blueprints(self)
Definition: models.py:245
bool _create_file(self, Blueprint blueprint, str blueprint_path, bool allow_override)
Definition: models.py:334
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
str humanize_error(HomeAssistant hass, vol.Invalid validation_error, str domain, dict config, str|None link, int max_sub_error_length=MAX_VALIDATION_ERROR_ITEM_LENGTH)
Definition: config.py:520