Migrate updater to aiohttp (#7387)

* Migrate updater to aiohttp

* Fix tests

* Update updater.py

* Docs
This commit is contained in:
Paulus Schoutsen 2017-05-01 23:29:01 -07:00 committed by GitHub
parent da2521a299
commit 8ea6c7319a
4 changed files with 222 additions and 164 deletions

View File

@ -4,22 +4,25 @@ Support to check for available updates.
For more details about this component, please refer to the documentation at For more details about this component, please refer to the documentation at
https://home-assistant.io/components/updater/ https://home-assistant.io/components/updater/
""" """
import asyncio
import json import json
import logging import logging
import os import os
import platform import platform
import uuid import uuid
from datetime import datetime, timedelta from datetime import timedelta
# pylint: disable=no-name-in-module, import-error # pylint: disable=no-name-in-module, import-error
from distutils.version import StrictVersion from distutils.version import StrictVersion
import requests import aiohttp
import async_timeout
import voluptuous as vol import voluptuous as vol
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from homeassistant.const import __version__ as CURRENT_VERSION from homeassistant.const import (
from homeassistant.const import ATTR_FRIENDLY_NAME ATTR_FRIENDLY_NAME, __version__ as CURRENT_VERSION)
from homeassistant.helpers import event from homeassistant.helpers import event
REQUIREMENTS = ['distro==1.0.4'] REQUIREMENTS = ['distro==1.0.4']
@ -67,59 +70,63 @@ def _load_uuid(hass, filename=UPDATER_UUID_FILE):
return _create_uuid(hass, filename) return _create_uuid(hass, filename)
def setup(hass, config): @asyncio.coroutine
def async_setup(hass, config):
"""Set up the updater component.""" """Set up the updater component."""
if 'dev' in CURRENT_VERSION: if 'dev' in CURRENT_VERSION:
# This component only makes sense in release versions # This component only makes sense in release versions
_LOGGER.warning("Running on 'dev', only analytics will be submitted") _LOGGER.warning("Running on 'dev', only analytics will be submitted")
config = config.get(DOMAIN, {}) config = config.get(DOMAIN, {})
huuid = _load_uuid(hass) if config.get(CONF_REPORTING) else None if config.get(CONF_REPORTING):
huuid = yield from hass.async_add_job(_load_uuid, hass)
else:
huuid = None
@asyncio.coroutine
def check_new_version(now):
"""Check if a new version is available and report if one is."""
result = yield from get_newest_version(hass, huuid)
if result is None:
return
newest, releasenotes = result
if newest is None or 'dev' in CURRENT_VERSION:
return
if StrictVersion(newest) > StrictVersion(CURRENT_VERSION):
_LOGGER.info("The latest available version is %s", newest)
hass.states.async_set(
ENTITY_ID, newest, {ATTR_FRIENDLY_NAME: 'Update Available',
ATTR_RELEASE_NOTES: releasenotes}
)
elif StrictVersion(newest) == StrictVersion(CURRENT_VERSION):
_LOGGER.info(
"You are on the latest version (%s) of Home Assistant", newest)
# Update daily, start 1 hour after startup # Update daily, start 1 hour after startup
_dt = datetime.now() + timedelta(hours=1) _dt = dt_util.utcnow() + timedelta(hours=1)
event.track_time_change( event.async_track_utc_time_change(
hass, lambda _: check_newest_version(hass, huuid), hass, check_new_version,
hour=_dt.hour, minute=_dt.minute, second=_dt.second) hour=_dt.hour, minute=_dt.minute, second=_dt.second)
return True return True
def check_newest_version(hass, huuid): @asyncio.coroutine
"""Check if a new version is available and report if one is.""" def get_system_info(hass):
result = get_newest_version(huuid) """Return info about the system."""
if result is None:
return
newest, releasenotes = result
if newest is None or 'dev' in CURRENT_VERSION:
return
if StrictVersion(newest) > StrictVersion(CURRENT_VERSION):
_LOGGER.info("The latest available version is %s", newest)
hass.states.set(
ENTITY_ID, newest, {ATTR_FRIENDLY_NAME: 'Update Available',
ATTR_RELEASE_NOTES: releasenotes}
)
elif StrictVersion(newest) == StrictVersion(CURRENT_VERSION):
_LOGGER.info("You are on the latest version (%s) of Home Assistant",
newest)
def get_newest_version(huuid):
"""Get the newest Home Assistant version."""
info_object = { info_object = {
'arch': platform.machine(), 'arch': platform.machine(),
'dev': ('dev' in CURRENT_VERSION), 'dev': 'dev' in CURRENT_VERSION,
'docker': False, 'docker': False,
'os_name': platform.system(), 'os_name': platform.system(),
'python_version': platform.python_version(), 'python_version': platform.python_version(),
'timezone': dt_util.DEFAULT_TIME_ZONE.zone, 'timezone': dt_util.DEFAULT_TIME_ZONE.zone,
'uuid': huuid,
'version': CURRENT_VERSION, 'version': CURRENT_VERSION,
'virtualenv': (os.environ.get('VIRTUAL_ENV') is not None), 'virtualenv': os.environ.get('VIRTUAL_ENV') is not None,
} }
if platform.system() == 'Windows': if platform.system() == 'Windows':
@ -130,32 +137,44 @@ def get_newest_version(huuid):
info_object['os_version'] = platform.release() info_object['os_version'] = platform.release()
elif platform.system() == 'Linux': elif platform.system() == 'Linux':
import distro import distro
linux_dist = distro.linux_distribution(full_distribution_name=False) linux_dist = yield from hass.async_add_job(
distro.linux_distribution, False)
info_object['distribution'] = linux_dist[0] info_object['distribution'] = linux_dist[0]
info_object['os_version'] = linux_dist[1] info_object['os_version'] = linux_dist[1]
info_object['docker'] = os.path.isfile('/.dockerenv') info_object['docker'] = os.path.isfile('/.dockerenv')
if not huuid: return info_object
@asyncio.coroutine
def get_newest_version(hass, huuid):
"""Get the newest Home Assistant version."""
if huuid:
info_object = yield from get_system_info(hass)
info_object['huuid'] = huuid
else:
info_object = {} info_object = {}
res = None session = async_get_clientsession(hass)
try: try:
req = requests.post(UPDATER_URL, json=info_object, timeout=5) with async_timeout.timeout(5, loop=hass.loop):
res = req.json() req = yield from session.post(UPDATER_URL, json=info_object)
res = RESPONSE_SCHEMA(res)
_LOGGER.info(("Submitted analytics to Home Assistant servers. " _LOGGER.info(("Submitted analytics to Home Assistant servers. "
"Information submitted includes %s"), info_object) "Information submitted includes %s"), info_object)
return (res['version'], res['release-notes']) except (asyncio.TimeoutError, aiohttp.ClientError):
except requests.RequestException:
_LOGGER.error("Could not contact Home Assistant Update to check " _LOGGER.error("Could not contact Home Assistant Update to check "
"for updates") "for updates")
return None return None
try:
res = yield from req.json()
except ValueError: except ValueError:
_LOGGER.error("Received invalid response from Home Assistant Update") _LOGGER.error("Received invalid JSON from Home Assistant Update")
return None return None
try:
res = RESPONSE_SCHEMA(res)
return (res['version'], res['release-notes'])
except vol.Invalid: except vol.Invalid:
_LOGGER.error('Got unexpected response: %s', res) _LOGGER.error('Got unexpected response: %s', res)
return None return None

View File

@ -1,9 +1,10 @@
"""Test the helper method for writing tests.""" """Test the helper method for writing tests."""
import asyncio import asyncio
import functools as ft
import os import os
import sys import sys
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock, Mock
from io import StringIO from io import StringIO
import logging import logging
import threading import threading
@ -36,6 +37,21 @@ _LOGGER = logging.getLogger(__name__)
INST_COUNT = 0 INST_COUNT = 0
def threadsafe_callback_factory(func):
"""Create threadsafe functions out of callbacks.
Callback needs to have `hass` as first argument.
"""
@ft.wraps(func)
def threadsafe(*args, **kwargs):
"""Call func threadsafe."""
hass = args[0]
run_callback_threadsafe(
hass.loop, ft.partial(func, *args, **kwargs)).result()
return threadsafe
def get_test_config_dir(*add_path): def get_test_config_dir(*add_path):
"""Return a path to a test config dir.""" """Return a path to a test config dir."""
return os.path.join(os.path.dirname(__file__), 'testing_config', *add_path) return os.path.join(os.path.dirname(__file__), 'testing_config', *add_path)
@ -93,8 +109,8 @@ def async_test_home_assistant(loop):
def async_add_job(target, *args): def async_add_job(target, *args):
"""Add a magic mock.""" """Add a magic mock."""
if isinstance(target, MagicMock): if isinstance(target, Mock):
return return mock_coro(target())
return orig_async_add_job(target, *args) return orig_async_add_job(target, *args)
hass.async_add_job = async_add_job hass.async_add_job = async_add_job
@ -177,15 +193,16 @@ def async_fire_mqtt_message(hass, topic, payload, qos=0):
payload, qos) payload, qos)
def fire_mqtt_message(hass, topic, payload, qos=0): fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message)
"""Fire the MQTT message."""
run_callback_threadsafe(
hass.loop, async_fire_mqtt_message, hass, topic, payload, qos).result()
def fire_time_changed(hass, time): @ha.callback
def async_fire_time_changed(hass, time):
"""Fire a time changes event.""" """Fire a time changes event."""
hass.bus.fire(EVENT_TIME_CHANGED, {'now': time}) hass.bus.async_fire(EVENT_TIME_CHANGED, {'now': time})
fire_time_changed = threadsafe_callback_factory(async_fire_time_changed)
def fire_service_discovered(hass, service, info): def fire_service_discovered(hass, service, info):
@ -271,6 +288,7 @@ def mock_mqtt_component(hass):
return mock_mqtt return mock_mqtt
@ha.callback
def mock_component(hass, component): def mock_component(hass, component):
"""Mock a component is setup.""" """Mock a component is setup."""
if component in hass.config.components: if component in hass.config.components:
@ -417,16 +435,11 @@ def patch_yaml_files(files_dict, endswith=True):
def mock_coro(return_value=None): def mock_coro(return_value=None):
"""Helper method to return a coro that returns a value.""" """Helper method to return a coro that returns a value."""
@asyncio.coroutine return mock_coro_func(return_value)()
def coro():
"""Fake coroutine."""
return return_value
return coro()
def mock_coro_func(return_value=None): def mock_coro_func(return_value=None):
"""Helper method to return a coro that returns a value.""" """Helper method to create a coro function that returns a value."""
@asyncio.coroutine @asyncio.coroutine
def coro(*args, **kwargs): def coro(*args, **kwargs):
"""Fake coroutine.""" """Fake coroutine."""

View File

@ -8,7 +8,7 @@ from homeassistant.bootstrap import async_setup_component
from homeassistant.components import discovery from homeassistant.components import discovery
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from tests.common import mock_coro, fire_time_changed from tests.common import mock_coro, async_fire_time_changed
# One might consider to "mock" services, but it's easy enough to just use # One might consider to "mock" services, but it's easy enough to just use
# what is already available. # what is already available.
@ -47,7 +47,7 @@ def mock_discovery(hass, discoveries, config=BASE_CONFIG):
return_value=mock_coro()) as mock_discover, \ return_value=mock_coro()) as mock_discover, \
patch('homeassistant.components.discovery.async_load_platform', patch('homeassistant.components.discovery.async_load_platform',
return_value=mock_coro()) as mock_platform: return_value=mock_coro()) as mock_platform:
fire_time_changed(hass, utcnow()) async_fire_time_changed(hass, utcnow())
# Work around an issue where our loop.call_soon not get caught # Work around an issue where our loop.call_soon not get caught
yield from hass.async_block_till_done() yield from hass.async_block_till_done()
yield from hass.async_block_till_done() yield from hass.async_block_till_done()

View File

@ -1,132 +1,158 @@
"""The tests for the Updater component.""" """The tests for the Updater component."""
from datetime import datetime, timedelta import asyncio
import unittest from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch, Mock
import os
import requests from freezegun import freeze_time
import requests_mock import pytest
import voluptuous as vol
from homeassistant.setup import setup_component from homeassistant.setup import async_setup_component
from homeassistant.components import updater from homeassistant.components import updater
import homeassistant.util.dt as dt_util
from tests.common import ( from tests.common import async_fire_time_changed, mock_coro
assert_setup_component, fire_time_changed, get_test_home_assistant)
NEW_VERSION = '10000.0' NEW_VERSION = '10000.0'
MOCK_VERSION = '10.0'
# We need to use a 'real' looking version number to load the updater component MOCK_DEV_VERSION = '10.0.dev0'
MOCK_CURRENT_VERSION = '10.0' MOCK_HUUID = 'abcdefg'
MOCK_RESPONSE = {
'version': '0.15',
'release-notes': 'https://home-assistant.io'
}
class TestUpdater(unittest.TestCase): @pytest.fixture
"""Test the Updater component.""" def mock_get_newest_version():
"""Fixture to mock get_newest_version."""
with patch('homeassistant.components.updater.get_newest_version') as mock:
yield mock
hass = None
def setup_method(self, _): @pytest.fixture
"""Setup things to be run when tests are started.""" def mock_get_uuid():
self.hass = get_test_home_assistant() """Fixture to mock get_uuid."""
with patch('homeassistant.components.updater._load_uuid') as mock:
yield mock
def teardown_method(self, _):
"""Stop everything that was started."""
self.hass.stop()
@patch('homeassistant.components.updater.get_newest_version') @asyncio.coroutine
def test_new_version_shows_entity_on_start( # pylint: disable=invalid-name @freeze_time("Mar 15th, 2017")
self, mock_get_newest_version): def test_new_version_shows_entity_after_hour(hass, mock_get_uuid,
"""Test if new entity is created if new version is available.""" mock_get_newest_version):
mock_get_newest_version.return_value = (NEW_VERSION, '') """Test if new entity is created if new version is available."""
updater.CURRENT_VERSION = MOCK_CURRENT_VERSION mock_get_uuid.return_value = MOCK_HUUID
mock_get_newest_version.return_value = mock_coro((NEW_VERSION, ''))
with assert_setup_component(1) as config: res = yield from async_setup_component(
setup_component(self.hass, updater.DOMAIN, {updater.DOMAIN: {}}) hass, updater.DOMAIN, {updater.DOMAIN: {}})
_dt = datetime.now() + timedelta(hours=1) assert res, 'Updater failed to setup'
assert config['updater'] == {'reporting': True}
for secs in [-1, 0, 1]: with patch('homeassistant.components.updater.CURRENT_VERSION',
fire_time_changed(self.hass, _dt + timedelta(seconds=secs)) MOCK_VERSION):
self.hass.block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1))
yield from hass.async_block_till_done()
self.assertTrue(self.hass.states.is_state( assert hass.states.is_state(updater.ENTITY_ID, NEW_VERSION)
updater.ENTITY_ID, NEW_VERSION))
@patch('homeassistant.components.updater.get_newest_version')
def test_no_entity_on_same_version( # pylint: disable=invalid-name
self, mock_get_newest_version):
"""Test if no entity is created if same version."""
mock_get_newest_version.return_value = (MOCK_CURRENT_VERSION, '')
updater.CURRENT_VERSION = MOCK_CURRENT_VERSION
with assert_setup_component(1) as config: @asyncio.coroutine
assert setup_component( @freeze_time("Mar 15th, 2017")
self.hass, updater.DOMAIN, {updater.DOMAIN: {}}) def test_same_version_not_show_entity(hass, mock_get_uuid,
_dt = datetime.now() + timedelta(hours=1) mock_get_newest_version):
assert config['updater'] == {'reporting': True} """Test if new entity is created if new version is available."""
mock_get_uuid.return_value = MOCK_HUUID
mock_get_newest_version.return_value = mock_coro((MOCK_VERSION, ''))
self.assertIsNone(self.hass.states.get(updater.ENTITY_ID)) res = yield from async_setup_component(
hass, updater.DOMAIN, {updater.DOMAIN: {}})
assert res, 'Updater failed to setup'
mock_get_newest_version.return_value = (NEW_VERSION, '') with patch('homeassistant.components.updater.CURRENT_VERSION',
MOCK_VERSION):
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1))
yield from hass.async_block_till_done()
for secs in [-1, 0, 1]: assert hass.states.get(updater.ENTITY_ID) is None
fire_time_changed(self.hass, _dt + timedelta(seconds=secs))
self.hass.block_till_done()
self.assertTrue(self.hass.states.is_state(
updater.ENTITY_ID, NEW_VERSION))
@patch('homeassistant.components.updater.requests.post') @asyncio.coroutine
def test_errors_while_fetching_new_version( # pylint: disable=invalid-name @freeze_time("Mar 15th, 2017")
self, mock_get): def test_disable_reporting(hass, mock_get_uuid, mock_get_newest_version):
"""Test for errors while fetching the new version.""" """Test if new entity is created if new version is available."""
mock_get.side_effect = requests.RequestException mock_get_uuid.return_value = MOCK_HUUID
uuid = '0000' mock_get_newest_version.return_value = mock_coro((MOCK_VERSION, ''))
self.assertIsNone(updater.get_newest_version(uuid))
mock_get.side_effect = ValueError res = yield from async_setup_component(
self.assertIsNone(updater.get_newest_version(uuid)) hass, updater.DOMAIN, {updater.DOMAIN: {
'reporting': False
}})
assert res, 'Updater failed to setup'
mock_get.side_effect = vol.Invalid('Expected dictionary') with patch('homeassistant.components.updater.CURRENT_VERSION',
self.assertIsNone(updater.get_newest_version(uuid)) MOCK_VERSION):
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1))
yield from hass.async_block_till_done()
def test_uuid_function(self): assert hass.states.get(updater.ENTITY_ID) is None
"""Test if the uuid function works.""" call = mock_get_newest_version.mock_calls[0][1]
path = self.hass.config.path(updater.UPDATER_UUID_FILE) assert call[0] is hass
try: assert call[1] is None
# pylint: disable=protected-access
uuid = updater._load_uuid(self.hass)
assert os.path.isfile(path)
uuid2 = updater._load_uuid(self.hass)
assert uuid == uuid2
os.remove(path)
uuid2 = updater._load_uuid(self.hass)
assert uuid != uuid2
finally:
os.remove(path)
@requests_mock.Mocker()
def test_reporting_false_works(self, m):
"""Test we do not send any data."""
m.post(updater.UPDATER_URL,
json={'version': '0.15',
'release-notes': 'https://home-assistant.io'})
response = updater.get_newest_version(None) @asyncio.coroutine
def test_get_newest_version_no_analytics_when_no_huuid(hass, aioclient_mock):
"""Test we do not gather analytics when no huuid is passed in."""
aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE)
assert response == ('0.15', 'https://home-assistant.io') with patch('homeassistant.components.updater.get_system_info',
side_effect=Exception):
res = yield from updater.get_newest_version(hass, None)
assert res == (MOCK_RESPONSE['version'],
MOCK_RESPONSE['release-notes'])
history = m.request_history
assert len(history) == 1 @asyncio.coroutine
assert history[0].json() == {} def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock):
"""Test we do not gather analytics when no huuid is passed in."""
aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE)
@patch('homeassistant.components.updater.get_newest_version') with patch('homeassistant.components.updater.get_system_info',
def test_error_during_fetch_works( Mock(return_value=mock_coro({'fake': 'bla'}))):
self, mock_get_newest_version): res = yield from updater.get_newest_version(hass, MOCK_HUUID)
"""Test if no entity is created if same version.""" assert res == (MOCK_RESPONSE['version'],
mock_get_newest_version.return_value = None MOCK_RESPONSE['release-notes'])
updater.check_newest_version(self.hass, None)
self.assertIsNone(self.hass.states.get(updater.ENTITY_ID)) @asyncio.coroutine
def test_error_fetching_new_version_timeout(hass):
"""Test we do not gather analytics when no huuid is passed in."""
with patch('homeassistant.components.updater.get_system_info',
Mock(return_value=mock_coro({'fake': 'bla'}))), \
patch('async_timeout.timeout', side_effect=asyncio.TimeoutError):
res = yield from updater.get_newest_version(hass, MOCK_HUUID)
assert res is None
@asyncio.coroutine
def test_error_fetching_new_version_bad_json(hass, aioclient_mock):
"""Test we do not gather analytics when no huuid is passed in."""
aioclient_mock.post(updater.UPDATER_URL, text='not json')
with patch('homeassistant.components.updater.get_system_info',
Mock(return_value=mock_coro({'fake': 'bla'}))):
res = yield from updater.get_newest_version(hass, MOCK_HUUID)
assert res is None
@asyncio.coroutine
def test_error_fetching_new_version_invalid_response(hass, aioclient_mock):
"""Test we do not gather analytics when no huuid is passed in."""
aioclient_mock.post(updater.UPDATER_URL, json={
'version': '0.15'
# 'release-notes' is missing
})
with patch('homeassistant.components.updater.get_system_info',
Mock(return_value=mock_coro({'fake': 'bla'}))):
res = yield from updater.get_newest_version(hass, MOCK_HUUID)
assert res is None