Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lazy loading of service descriptions #11479

Merged
merged 21 commits into from
Jan 7, 2018
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions homeassistant/components/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
__version__)
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.state import AsyncTrackStates
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.helpers import template
from homeassistant.components.http import HomeAssistantView

Expand Down Expand Up @@ -293,10 +294,11 @@ class APIServicesView(HomeAssistantView):
url = URL_API_SERVICES
name = "api:services"

@ha.callback
@asyncio.coroutine
def get(self, request):
"""Get registered services."""
return self.json(async_services_json(request.app['hass']))
services = yield from async_services_json(request.app['hass'])
return self.json(services)


class APIDomainServicesView(HomeAssistantView):
Expand Down Expand Up @@ -355,10 +357,12 @@ def post(self, request):
HTTP_BAD_REQUEST)


@asyncio.coroutine
def async_services_json(hass):
"""Generate services data to JSONify."""
descriptions = yield from async_get_all_descriptions(hass)
return [{"domain": key, "services": value}
for key, value in hass.services.async_services().items()]
for key, value in descriptions.items()]


def async_events_json(hass):
Expand Down
17 changes: 4 additions & 13 deletions homeassistant/components/light/lifx.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import asyncio
import sys
import math
from os import path
from functools import partial
from datetime import timedelta

Expand All @@ -22,7 +21,6 @@
SUPPORT_XY_COLOR, SUPPORT_TRANSITION, SUPPORT_EFFECT,
VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT,
preprocess_turn_on_alternatives)
from homeassistant.config import load_yaml_config_file
from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP
from homeassistant import util
from homeassistant.core import callback
Expand Down Expand Up @@ -210,13 +208,10 @@ def __init__(self, hass, async_add_devices):
self.async_add_devices = async_add_devices
self.effects_conductor = aiolifx_effects().Conductor(loop=hass.loop)

descriptions = load_yaml_config_file(
path.join(path.dirname(__file__), 'services.yaml'))
self.register_set_state()
self.register_effects()

self.register_set_state(descriptions)
self.register_effects(descriptions)

def register_set_state(self, descriptions):
def register_set_state(self):
"""Register the LIFX set_state service call."""
@asyncio.coroutine
def async_service_handle(service):
Expand All @@ -231,10 +226,9 @@ def async_service_handle(service):

self.hass.services.async_register(
DOMAIN, SERVICE_LIFX_SET_STATE, async_service_handle,
descriptions.get(SERVICE_LIFX_SET_STATE),
schema=LIFX_SET_STATE_SCHEMA)

def register_effects(self, descriptions):
def register_effects(self):
"""Register the LIFX effects as hass service calls."""
@asyncio.coroutine
def async_service_handle(service):
Expand All @@ -246,17 +240,14 @@ def async_service_handle(service):

self.hass.services.async_register(
DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle,
descriptions.get(SERVICE_EFFECT_PULSE),
schema=LIFX_EFFECT_PULSE_SCHEMA)

self.hass.services.async_register(
DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle,
descriptions.get(SERVICE_EFFECT_COLORLOOP),
schema=LIFX_EFFECT_COLORLOOP_SCHEMA)

self.hass.services.async_register(
DOMAIN, SERVICE_EFFECT_STOP, async_service_handle,
descriptions.get(SERVICE_EFFECT_STOP),
schema=LIFX_EFFECT_STOP_SCHEMA)

@asyncio.coroutine
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/scene/services.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Describes the format for available scene services

turn_on:
description: Activate a scene.
fields:
entity_id:
description: Name(s) of scenes to turn on
example: 'scene.romantic'
12 changes: 9 additions & 3 deletions homeassistant/components/websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from homeassistant.core import callback
from homeassistant.remote import JSONEncoder
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.auth import validate_password
from homeassistant.components.http.const import KEY_AUTHENTICATED
Expand Down Expand Up @@ -436,7 +437,7 @@ def handle_unsubscribe_events(self, msg):
def handle_call_service(self, msg):
"""Handle call service command.

This is a coroutine.
Async friendly.
"""
msg = CALL_SERVICE_MESSAGE_SCHEMA(msg)

Expand Down Expand Up @@ -466,8 +467,13 @@ def handle_get_services(self, msg):
"""
msg = GET_SERVICES_MESSAGE_SCHEMA(msg)

self.to_write.put_nowait(result_message(
msg['id'], self.hass.services.async_services()))
@asyncio.coroutine
def call_service_helper(msg):
"""Call a service and fire complete message."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function name and docstring are stale. This function doesn't call a service.

descriptions = yield from async_get_all_descriptions(self.hass)
self.send_message_outside(result_message(msg['id'], descriptions))

self.hass.async_add_job(call_service_helper(msg))

def handle_get_config(self, msg):
"""Handle get config command.
Expand Down
28 changes: 4 additions & 24 deletions homeassistant/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,25 +754,15 @@ def async_set(self, entity_id, new_state, attributes=None,
class Service(object):
"""Representation of a callable service."""

__slots__ = ['func', 'description', 'fields', 'schema',
'is_callback', 'is_coroutinefunction']
__slots__ = ['func', 'schema', 'is_callback', 'is_coroutinefunction']

def __init__(self, func, description, fields, schema):
def __init__(self, func, schema):
"""Initialize a service."""
self.func = func
self.description = description or ''
self.fields = fields or {}
self.schema = schema
self.is_callback = is_callback(func)
self.is_coroutinefunction = asyncio.iscoroutinefunction(func)

def as_dict(self):
"""Return dictionary representation of this service."""
return {
'description': self.description,
'fields': self.fields,
}


class ServiceCall(object):
"""Representation of a call to a service."""
Expand Down Expand Up @@ -826,9 +816,7 @@ def async_services(self):

This method must be run in the event loop.
"""
return {domain: {key: value.as_dict() for key, value
in self._services[domain].items()}
for domain in self._services}
return {domain: self._services[domain].copy() for domain in self._services}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (83 > 79 characters)


def has_service(self, domain, service):
"""Test if specified service exists.
Expand All @@ -842,9 +830,6 @@ def register(self, domain, service, service_func, description=None,
"""
Register a service.

Description is a dict containing key 'description' to describe
the service and a key 'fields' to describe the fields.

Schema is called to coerce and validate the service data.
"""
run_callback_threadsafe(
Expand All @@ -859,18 +844,13 @@ def async_register(self, domain, service, service_func, description=None,
"""
Register a service.

Description is a dict containing key 'description' to describe
the service and a key 'fields' to describe the fields.

Schema is called to coerce and validate the service data.

This method must be run in the event loop.
"""
domain = domain.lower()
service = service.lower()
description = description or {}
service_obj = Service(service_func, description.get('description'),
description.get('fields', {}), schema)
service_obj = Service(service_func, schema)

if domain in self._services:
self._services[domain][service] = service_obj
Expand Down
51 changes: 50 additions & 1 deletion homeassistant/helpers/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import logging
# pylint: disable=unused-import
from typing import Optional # NOQA
from os import path

import voluptuous as vol

from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant # NOQA
import homeassistant.core as ha
from homeassistant.exceptions import TemplateError
from homeassistant.loader import get_component, bind_hass
from homeassistant.config import load_yaml_config_file
import homeassistant.helpers.config_validation as cv
from homeassistant.util.async import run_coroutine_threadsafe

Expand All @@ -21,6 +23,8 @@

_LOGGER = logging.getLogger(__name__)

DESCRIPTION_CACHE = {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't store the description cache in a global. Instead store it in hass.data, which is a dict for globals during the runtime of a hass instance.



@bind_hass
def call_from_config(hass, config, blocking=False, variables=None,
Expand Down Expand Up @@ -112,3 +116,48 @@ def extract_entity_ids(hass, service_call, expand_group=True):
return [service_ent_id]

return service_ent_id


@bind_hass

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected 2 blank lines, found 1

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected 2 blank lines, found 1

def get_description(hass, domain, service):
"""Return the description (i.e. user documentation) for a service call."""

if domain == ha.DOMAIN:
import homeassistant.components as components
file = components.__file__
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

file is a python builtin. Please use another variable name, eg fil, comp_file or similar.

else:
file = get_component(domain).__file__

descriptions = DESCRIPTION_CACHE.get(file)
if descriptions is None:
descriptions = load_yaml_config_file(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call will do I/O. Since get_description will be called from async_get_all_descriptions, the function that does I/O needs to be scheduled on the executor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh. This was moved from the LIFX async_setup_platform but I guess that was also wrong.

I am a bit confused by the main/executor transitions available. What is the proper way to schedule the function in this situation?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the function that schedules the job for the executor, via hass.async_add_job, needs to be a coroutine to be able to yield and wait for the result, ie to have the descriptions ready. Not 💯 though. I'd wait for a core review before changing anything.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are correct Martin

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file might not exist

path.join(path.dirname(file), 'services.yaml'))
DESCRIPTION_CACHE[file] = descriptions

if domain == ha.DOMAIN:
description = descriptions[domain].get(service, {})
else:
description = descriptions.get(service, {})

return {
'description': description.get('description', ''),
'fields': description.get('fields', {})
}


@asyncio.coroutine
@bind_hass

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected 2 blank lines, found 1

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected 2 blank lines, found 1

def async_get_all_descriptions(hass):
"""Return the descriptions for all service calls."""

services = hass.services.async_services()
descriptions = {}
for domain in services:
descriptions[domain] = {}
for service in services[domain]:
description = yield from hass.async_add_job(
get_description, hass, domain, service)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we know that we're going to load all the descriptions, it would help if we preload the catch all services file (/components/services.yaml). That file is pretty heavy if we have to parse it X times.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should only be read and parsed once, that's what the DESCRIPTION_CACHE is for.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see now that you cache that file. I'm not sure if that is a great idea, it's big and a lot will be unused.

if description:
descriptions[domain][service] = description

return descriptions
5 changes: 1 addition & 4 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,10 +640,7 @@ def test_has_service(self):

def test_services(self):
"""Test services."""
expected = {
'test_domain': {'test_service': {'description': '', 'fields': {}}}
}
self.assertEqual(expected, self.services.services)
assert len(self.services.services) == 1

def test_call_with_blocking_done_in_time(self):
"""Test call with blocking."""
Expand Down