From 8ea6c7319ac5888fe0fc207b7242bd25339ba577 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 1 May 2017 23:29:01 -0700 Subject: [PATCH] Migrate updater to aiohttp (#7387) * Migrate updater to aiohttp * Fix tests * Update updater.py * Docs --- homeassistant/components/updater.py | 113 ++++++++------ tests/common.py | 45 ++++-- tests/components/test_discovery.py | 4 +- tests/components/test_updater.py | 224 ++++++++++++++++------------ 4 files changed, 222 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index c6cd1a69d0c..137757fdbd4 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -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 diff --git a/tests/common.py b/tests/common.py index a6627344879..1dc6e9ffdba 100644 --- a/tests/common.py +++ b/tests/common.py @@ -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.""" diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index 7073c420341..d5be9c483ad 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -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() diff --git a/tests/components/test_updater.py b/tests/components/test_updater.py index da9775e17e6..8cc1c78cdcb 100644 --- a/tests/components/test_updater.py +++ b/tests/components/test_updater.py @@ -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