From 24c110ad3cac25d73c34e61ccf40148f171065ed Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 6 Nov 2018 02:12:31 +0100 Subject: [PATCH] Lovelace: Duplicate ID check on load config + caching (#18152) * Add caching + dupl. ID check * duplicate imports... * lint * remove for/else * found * Missed one... --- homeassistant/components/lovelace/__init__.py | 116 ++++++++++++------ 1 file changed, 78 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index a8cde6a2b93..39644bd047b 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -6,7 +6,9 @@ at https://www.home-assistant.io/lovelace/ """ from functools import wraps import logging +import os from typing import Dict, List, Union +import time import uuid import voluptuous as vol @@ -18,6 +20,7 @@ import homeassistant.util.ruamel_yaml as yaml _LOGGER = logging.getLogger(__name__) DOMAIN = 'lovelace' +LOVELACE_DATA = 'lovelace' LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name @@ -133,9 +136,37 @@ class DuplicateIdError(HomeAssistantError): """Duplicate ID's.""" -def load_config(fname: str) -> JSON_TYPE: +def load_config(hass) -> JSON_TYPE: """Load a YAML file.""" - return yaml.load_yaml(fname, False) + fname = hass.config.path(LOVELACE_CONFIG_FILE) + + # Check for a cached version of the config + if LOVELACE_DATA in hass.data: + config, last_update = hass.data[LOVELACE_DATA] + modtime = os.path.getmtime(fname) + if config and last_update > modtime: + return config + + config = yaml.load_yaml(fname, False) + seen_card_ids = set() + seen_view_ids = set() + for view in config.get('views', []): + view_id = str(view.get('id', '')) + if view_id: + if view_id in seen_view_ids: + raise DuplicateIdError( + 'ID `{}` has multiple occurances in views'.format(view_id)) + seen_view_ids.add(view_id) + for card in view.get('cards', []): + card_id = str(card.get('id', '')) + if card_id: + if card_id in seen_card_ids: + raise DuplicateIdError( + 'ID `{}` has multiple occurances in cards' + .format(card_id)) + seen_card_ids.add(card_id) + hass.data[LOVELACE_DATA] = (config, time.time()) + return config def migrate_config(fname: str) -> None: @@ -301,35 +332,39 @@ def get_view(fname: str, view_id: str, data_format: str = FORMAT_YAML) -> None: """Get view without it's cards.""" round_trip = data_format == FORMAT_YAML config = yaml.load_yaml(fname, round_trip) + found = None for view in config.get('views', []): - if str(view.get('id', '')) != view_id: - continue - del view['cards'] - if data_format == FORMAT_YAML: - return yaml.object_to_yaml(view) - return view + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + del found['cards'] + if data_format == FORMAT_YAML: + return yaml.object_to_yaml(found) + return found def update_view(fname: str, view_id: str, view_config, data_format: str = FORMAT_YAML) -> None: """Update view.""" config = yaml.load_yaml(fname, True) + found = None for view in config.get('views', []): - if str(view.get('id', '')) != view_id: - continue - if data_format == FORMAT_YAML: - view_config = yaml.yaml_to_object(view_config) - view_config['cards'] = view.get('cards', []) - view.clear() - view.update(view_config) - yaml.save_yaml(fname, config) - return - - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) + if data_format == FORMAT_YAML: + view_config = yaml.yaml_to_object(view_config) + view_config['cards'] = found.get('cards', []) + found.clear() + found.update(view_config) + yaml.save_yaml(fname, config) def add_view(fname: str, view_config: str, @@ -350,30 +385,34 @@ def move_view(fname: str, view_id: str, position: int) -> None: """Move a view to a different position.""" config = yaml.load_yaml(fname, True) views = config.get('views', []) + found = None for view in views: - if str(view.get('id', '')) != view_id: - continue - views.insert(position, views.pop(views.index(view))) - yaml.save_yaml(fname, config) - return + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + views.insert(position, views.pop(views.index(found))) + yaml.save_yaml(fname, config) def delete_view(fname: str, view_id: str) -> None: """Delete a view.""" config = yaml.load_yaml(fname, True) views = config.get('views', []) + found = None for view in views: - if str(view.get('id', '')) != view_id: - continue - views.pop(views.index(view)) - yaml.save_yaml(fname, config) - return + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + views.pop(views.index(found)) + yaml.save_yaml(fname, config) async def async_setup(hass, config): @@ -445,6 +484,8 @@ def handle_yaml_errors(func): error = 'unsupported_error', str(err) except yaml.WriteError as err: error = 'write_error', str(err) + except DuplicateIdError as err: + error = 'duplicate_id', str(err) except CardNotFoundError as err: error = 'card_not_found', str(err) except ViewNotFoundError as err: @@ -464,8 +505,7 @@ def handle_yaml_errors(func): @handle_yaml_errors async def websocket_lovelace_config(hass, connection, msg): """Send Lovelace UI config over WebSocket configuration.""" - return await hass.async_add_executor_job( - load_config, hass.config.path(LOVELACE_CONFIG_FILE)) + return await hass.async_add_executor_job(load_config, hass) @websocket_api.async_response