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
https://home-assistant.io/components/updater/
"""
import asyncio
import json
import logging
import os
import platform
import uuid
from datetime import datetime, timedelta
from datetime import timedelta
# pylint: disable=no-name-in-module, import-error
from distutils.version import StrictVersion
import requests
import aiohttp
import async_timeout
import voluptuous as vol
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
from homeassistant.const import __version__ as CURRENT_VERSION
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.const import (
ATTR_FRIENDLY_NAME, __version__ as CURRENT_VERSION)
from homeassistant.helpers import event
REQUIREMENTS = ['distro==1.0.4']
@ -67,59 +70,63 @@ def _load_uuid(hass, filename=UPDATER_UUID_FILE):
return _create_uuid(hass, filename)
def setup(hass, config):
@asyncio.coroutine
def async_setup(hass, config):
"""Set up the updater component."""
if 'dev' in CURRENT_VERSION:
# This component only makes sense in release versions
_LOGGER.warning("Running on 'dev', only analytics will be submitted")
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
_dt = datetime.now() + timedelta(hours=1)
event.track_time_change(
hass, lambda _: check_newest_version(hass, huuid),
_dt = dt_util.utcnow() + timedelta(hours=1)
event.async_track_utc_time_change(
hass, check_new_version,
hour=_dt.hour, minute=_dt.minute, second=_dt.second)
return True
def check_newest_version(hass, huuid):
"""Check if a new version is available and report if one is."""
result = get_newest_version(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.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."""
@asyncio.coroutine
def get_system_info(hass):
"""Return info about the system."""
info_object = {
'arch': platform.machine(),
'dev': ('dev' in CURRENT_VERSION),
'dev': 'dev' in CURRENT_VERSION,
'docker': False,
'os_name': platform.system(),
'python_version': platform.python_version(),
'timezone': dt_util.DEFAULT_TIME_ZONE.zone,
'uuid': huuid,
'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':
@ -130,32 +137,44 @@ def get_newest_version(huuid):
info_object['os_version'] = platform.release()
elif platform.system() == 'Linux':
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['os_version'] = linux_dist[1]
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 = {}
res = None
session = async_get_clientsession(hass)
try:
req = requests.post(UPDATER_URL, json=info_object, timeout=5)
res = req.json()
res = RESPONSE_SCHEMA(res)
with async_timeout.timeout(5, loop=hass.loop):
req = yield from session.post(UPDATER_URL, json=info_object)
_LOGGER.info(("Submitted analytics to Home Assistant servers. "
"Information submitted includes %s"), info_object)
return (res['version'], res['release-notes'])
except requests.RequestException:
except (asyncio.TimeoutError, aiohttp.ClientError):
_LOGGER.error("Could not contact Home Assistant Update to check "
"for updates")
return None
try:
res = yield from req.json()
except ValueError:
_LOGGER.error("Received invalid response from Home Assistant Update")
_LOGGER.error("Received invalid JSON from Home Assistant Update")
return None
try:
res = RESPONSE_SCHEMA(res)
return (res['version'], res['release-notes'])
except vol.Invalid:
_LOGGER.error('Got unexpected response: %s', res)
return None

View file

@ -1,9 +1,10 @@
"""Test the helper method for writing tests."""
import asyncio
import functools as ft
import os
import sys
from datetime import timedelta
from unittest.mock import patch, MagicMock
from unittest.mock import patch, MagicMock, Mock
from io import StringIO
import logging
import threading
@ -36,6 +37,21 @@ _LOGGER = logging.getLogger(__name__)
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):
"""Return a path to a test config dir."""
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):
"""Add a magic mock."""
if isinstance(target, MagicMock):
return
if isinstance(target, Mock):
return mock_coro(target())
return orig_async_add_job(target, *args)
hass.async_add_job = async_add_job
@ -177,15 +193,16 @@ def async_fire_mqtt_message(hass, topic, payload, qos=0):
payload, qos)
def fire_mqtt_message(hass, topic, payload, qos=0):
"""Fire the MQTT message."""
run_callback_threadsafe(
hass.loop, async_fire_mqtt_message, hass, topic, payload, qos).result()
fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message)
def fire_time_changed(hass, time):
@ha.callback
def async_fire_time_changed(hass, time):
"""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):
@ -271,6 +288,7 @@ def mock_mqtt_component(hass):
return mock_mqtt
@ha.callback
def mock_component(hass, component):
"""Mock a component is setup."""
if component in hass.config.components:
@ -417,16 +435,11 @@ def patch_yaml_files(files_dict, endswith=True):
def mock_coro(return_value=None):
"""Helper method to return a coro that returns a value."""
@asyncio.coroutine
def coro():
"""Fake coroutine."""
return return_value
return coro()
return mock_coro_func(return_value)()
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
def coro(*args, **kwargs):
"""Fake coroutine."""

View file

@ -8,7 +8,7 @@ from homeassistant.bootstrap import async_setup_component
from homeassistant.components import discovery
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
# what is already available.
@ -47,7 +47,7 @@ def mock_discovery(hass, discoveries, config=BASE_CONFIG):
return_value=mock_coro()) as mock_discover, \
patch('homeassistant.components.discovery.async_load_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
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."""
from datetime import datetime, timedelta
import unittest
from unittest.mock import patch
import os
import asyncio
from datetime import timedelta
from unittest.mock import patch, Mock
import requests
import requests_mock
import voluptuous as vol
from freezegun import freeze_time
import pytest
from homeassistant.setup import setup_component
from homeassistant.setup import async_setup_component
from homeassistant.components import updater
from tests.common import (
assert_setup_component, fire_time_changed, get_test_home_assistant)
import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed, mock_coro
NEW_VERSION = '10000.0'
# We need to use a 'real' looking version number to load the updater component
MOCK_CURRENT_VERSION = '10.0'
MOCK_VERSION = '10.0'
MOCK_DEV_VERSION = '10.0.dev0'
MOCK_HUUID = 'abcdefg'
MOCK_RESPONSE = {
'version': '0.15',
'release-notes': 'https://home-assistant.io'
}
class TestUpdater(unittest.TestCase):
"""Test the Updater component."""
@pytest.fixture
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, _):
"""Setup things to be run when tests are started."""
self.hass = get_test_home_assistant()
@pytest.fixture
def mock_get_uuid():
"""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')
def test_new_version_shows_entity_on_start( # pylint: disable=invalid-name
self, mock_get_newest_version):
"""Test if new entity is created if new version is available."""
mock_get_newest_version.return_value = (NEW_VERSION, '')
updater.CURRENT_VERSION = MOCK_CURRENT_VERSION
@asyncio.coroutine
@freeze_time("Mar 15th, 2017")
def test_new_version_shows_entity_after_hour(hass, mock_get_uuid,
mock_get_newest_version):
"""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((NEW_VERSION, ''))
with assert_setup_component(1) as config:
setup_component(self.hass, updater.DOMAIN, {updater.DOMAIN: {}})
_dt = datetime.now() + timedelta(hours=1)
assert config['updater'] == {'reporting': True}
res = yield from async_setup_component(
hass, updater.DOMAIN, {updater.DOMAIN: {}})
assert res, 'Updater failed to setup'
for secs in [-1, 0, 1]:
fire_time_changed(self.hass, _dt + timedelta(seconds=secs))
self.hass.block_till_done()
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()
self.assertTrue(self.hass.states.is_state(
updater.ENTITY_ID, NEW_VERSION))
assert hass.states.is_state(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:
assert setup_component(
self.hass, updater.DOMAIN, {updater.DOMAIN: {}})
_dt = datetime.now() + timedelta(hours=1)
assert config['updater'] == {'reporting': True}
@asyncio.coroutine
@freeze_time("Mar 15th, 2017")
def test_same_version_not_show_entity(hass, mock_get_uuid,
mock_get_newest_version):
"""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]:
fire_time_changed(self.hass, _dt + timedelta(seconds=secs))
self.hass.block_till_done()
assert hass.states.get(updater.ENTITY_ID) is None
self.assertTrue(self.hass.states.is_state(
updater.ENTITY_ID, NEW_VERSION))
@patch('homeassistant.components.updater.requests.post')
def test_errors_while_fetching_new_version( # pylint: disable=invalid-name
self, mock_get):
"""Test for errors while fetching the new version."""
mock_get.side_effect = requests.RequestException
uuid = '0000'
self.assertIsNone(updater.get_newest_version(uuid))
@asyncio.coroutine
@freeze_time("Mar 15th, 2017")
def test_disable_reporting(hass, mock_get_uuid, mock_get_newest_version):
"""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, ''))
mock_get.side_effect = ValueError
self.assertIsNone(updater.get_newest_version(uuid))
res = yield from async_setup_component(
hass, updater.DOMAIN, {updater.DOMAIN: {
'reporting': False
}})
assert res, 'Updater failed to setup'
mock_get.side_effect = vol.Invalid('Expected dictionary')
self.assertIsNone(updater.get_newest_version(uuid))
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()
def test_uuid_function(self):
"""Test if the uuid function works."""
path = self.hass.config.path(updater.UPDATER_UUID_FILE)
try:
# 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)
assert hass.states.get(updater.ENTITY_ID) is None
call = mock_get_newest_version.mock_calls[0][1]
assert call[0] is hass
assert call[1] is None
@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
assert history[0].json() == {}
@asyncio.coroutine
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')
def test_error_during_fetch_works(
self, mock_get_newest_version):
"""Test if no entity is created if same version."""
mock_get_newest_version.return_value = None
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 == (MOCK_RESPONSE['version'],
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