integration with Remember The Milk. (#9803)

* MVP integration with Remember The Milk.

This version offers a service allowing you to create new issues in Remember The Milk.

* fixed pylint issue with import path

* - added files to .coveragerc as the server inerface is hard to test
- added tests for config file handling

* fixed lint error

* added missing docstrings

* removed stray edit

* fixed minor issues reported by @fabaff

* changed naming of the service, so that serveral accounts can be used

* added disclaimer

* moved service description to services.yaml

* fixed blank lines

* fixed structure of configuration

* added comment about httplib2

* renamed internal config file

* improved logging statements

* moved entry in services.yaml into separate folder.

Had to move the component itself as well.

* fixed static analysis findings

* mocked first test case

* fixed bug in config handling, fixed unit tests

* mocked second test case

* fixed line length

* fixed static analysis findings and failing test case

* also renamed file in .coveragerc

* control flow changes as requested by  @balloob
This commit is contained in:
ChristianKuehnel
2017-11-01 04:33:47 +01:00
committed by Paulus Schoutsen
parent 8c266f9266
commit 80a9539f97
5 changed files with 316 additions and 0 deletions

View File

@ -457,6 +457,7 @@ omit =
homeassistant/components/notify/yessssms.py
homeassistant/components/nuimo_controller.py
homeassistant/components/prometheus.py
homeassistant/components/remember_the_milk/__init__.py
homeassistant/components/remote/harmony.py
homeassistant/components/remote/itach.py
homeassistant/components/scene/hunterdouglas_powerview.py

View File

@ -0,0 +1,251 @@
"""Component to interact with Remember The Milk.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/remember_the_milk/
Minimum viable product, it currently only support creating new tasks in your
Remember The Milk (https://www.rememberthemilk.com/) account.
This product uses the Remember The Milk API but is not endorsed or certified
by Remember The Milk.
"""
import logging
import os
import json
import voluptuous as vol
from homeassistant.config import load_yaml_config_file
from homeassistant.const import (CONF_API_KEY, STATE_OK, CONF_TOKEN, CONF_NAME)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
# httplib2 is a transitive dependency from RtmAPI. If this dependency is not
# set explicitly, the library does not work.
REQUIREMENTS = ['RtmAPI==0.7.0', 'httplib2==0.10.3']
_LOGGER = logging.getLogger(__name__)
DOMAIN = 'remember_the_milk'
DEFAULT_NAME = DOMAIN
GROUP_NAME_RTM = 'remember the milk accounts'
CONF_SHARED_SECRET = 'shared_secret'
RTM_SCHEMA = vol.Schema({
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_API_KEY): cv.string,
vol.Required(CONF_SHARED_SECRET): cv.string,
})
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])
}, extra=vol.ALLOW_EXTRA)
CONFIG_FILE_NAME = '.remember_the_milk.conf'
SERVICE_CREATE_TASK = 'create_task'
SERVICE_SCHEMA_CREATE_TASK = vol.Schema({
vol.Required(CONF_NAME): cv.string,
})
def setup(hass, config):
"""Set up the remember_the_milk component."""
component = EntityComponent(_LOGGER, DOMAIN, hass,
group_name=GROUP_NAME_RTM)
descriptions = load_yaml_config_file(
os.path.join(os.path.dirname(__file__), 'services.yaml'))
stored_rtm_config = RememberTheMilkConfiguration(hass)
for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME]
_LOGGER.info("Adding Remember the milk account %s", account_name)
api_key = rtm_config[CONF_API_KEY]
shared_secret = rtm_config[CONF_SHARED_SECRET]
token = stored_rtm_config.get_token(account_name)
if token:
_LOGGER.debug("found token for account %s", account_name)
_create_instance(
hass, account_name, api_key, shared_secret, token,
stored_rtm_config, component, descriptions)
else:
_register_new_account(
hass, account_name, api_key, shared_secret,
stored_rtm_config, component, descriptions)
_LOGGER.debug("Finished adding all Remember the milk accounts")
return True
def _create_instance(hass, account_name, api_key, shared_secret,
token, stored_rtm_config, component, descriptions):
entity = RememberTheMilk(account_name, api_key, shared_secret,
token, stored_rtm_config)
component.add_entity(entity)
hass.services.async_register(
DOMAIN, '{}_create_task'.format(account_name), entity.create_task,
description=descriptions.get(SERVICE_CREATE_TASK),
schema=SERVICE_SCHEMA_CREATE_TASK)
def _register_new_account(hass, account_name, api_key, shared_secret,
stored_rtm_config, component, descriptions):
from rtmapi import Rtm
request_id = None
configurator = hass.components.configurator
api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop()
_LOGGER.debug('sent authentication request to server')
def register_account_callback(_):
"""Callback for configurator."""
api.retrieve_token(frob)
token = api.token
if api.token is None:
_LOGGER.error('Failed to register, please try again.')
configurator.notify_errors(
request_id,
'Failed to register, please try again.')
return
stored_rtm_config.set_token(account_name, token)
_LOGGER.debug('retrieved new token from server')
_create_instance(
hass, account_name, api_key, shared_secret, token,
stored_rtm_config, component, descriptions)
configurator.request_done(request_id)
request_id = configurator.async_request_config(
'{} - {}'.format(DOMAIN, account_name),
callback=register_account_callback,
description='You need to log in to Remember The Milk to' +
'connect your account. \n\n' +
'Step 1: Click on the link "Remember The Milk login"\n\n' +
'Step 2: Click on "login completed"',
link_name='Remember The Milk login',
link_url=url,
submit_caption="login completed",
)
class RememberTheMilkConfiguration(object):
"""Internal configuration data for RememberTheMilk class.
This class stores the authentication token it get from the backend.
"""
def __init__(self, hass):
"""Create new instance of configuration."""
self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
if not os.path.isfile(self._config_file_path):
self._config = dict()
return
try:
_LOGGER.debug('loading configuration from file: %s',
self._config_file_path)
with open(self._config_file_path, 'r') as config_file:
self._config = json.load(config_file)
except ValueError:
_LOGGER.error('failed to load configuration file, creating a '
'new one: %s', self._config_file_path)
self._config = dict()
def save_config(self):
"""Write the configuration to a file."""
with open(self._config_file_path, 'w') as config_file:
json.dump(self._config, config_file)
def get_token(self, profile_name):
"""Get the server token for a profile."""
if profile_name in self._config:
return self._config[profile_name][CONF_TOKEN]
return None
def set_token(self, profile_name, token):
"""Store a new server token for a profile."""
if profile_name not in self._config:
self._config[profile_name] = dict()
self._config[profile_name][CONF_TOKEN] = token
self.save_config()
def delete_token(self, profile_name):
"""Delete a token for a profile.
Usually called when the token has expired.
"""
self._config.pop(profile_name, None)
self.save_config()
class RememberTheMilk(Entity):
"""MVP implementation of an interface to Remember The Milk."""
def __init__(self, name, api_key, shared_secret, token, rtm_config):
"""Create new instance of Remember The Milk component."""
import rtmapi
self._name = name
self._api_key = api_key
self._shared_secret = shared_secret
self._token = token
self._rtm_config = rtm_config
self._rtm_api = rtmapi.Rtm(api_key, shared_secret, "delete", token)
self._token_valid = None
self._check_token()
_LOGGER.debug("instance created for account %s", self._name)
def _check_token(self):
"""Check if the API token is still valid.
If it is not valid any more, delete it from the configuration. This
will trigger a new authentication process.
"""
valid = self._rtm_api.token_valid()
if not valid:
_LOGGER.error('Token for account %s is invalid. You need to '
'register again!', self.name)
self._rtm_config.delete_token(self._name)
self._token_valid = False
else:
self._token_valid = True
return self._token_valid
def create_task(self, call):
"""Create a new task on Remember The Milk.
You can use the smart syntax to define the attribues of a new task,
e.g. "my task #some_tag ^today" will add tag "some_tag" and set the
due date to today.
"""
import rtmapi
try:
task_name = call.data.get('name')
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.rtm.tasks.add(
timeline=timeline, name=task_name, parse='1')
_LOGGER.debug('created new task "%s" in account %s',
task_name, self.name)
except rtmapi.RtmRequestFailedException as rtm_exception:
_LOGGER.error('Error creating new Remember The Milk task for '
'account %s: %s', self._name, rtm_exception)
return False
return True
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def state(self):
"""Return the state of the device."""
if not self._token_valid:
return 'API token invalid'
return STATE_OK

View File

@ -0,0 +1,9 @@
# Describes the format for available Remember The Milk services
create_task:
description: Create a new task in your Remember The Milk account
fields:
name:
description: name of the new task, you can use the smart syntax here
example: 'do this ^today #from_hass'

View File

@ -42,6 +42,9 @@ PyXiaomiGateway==0.6.0
# homeassistant.components.rpi_gpio
# RPi.GPIO==0.6.1
# homeassistant.components.remember_the_milk
RtmAPI==0.7.0
# homeassistant.components.media_player.sonos
SoCo==0.12
@ -332,6 +335,9 @@ home-assistant-frontend==20171030.0
# homeassistant.components.camera.onvif
http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a
# homeassistant.components.remember_the_milk
httplib2==0.10.3
# homeassistant.components.sensor.dht
# https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2

View File

@ -0,0 +1,49 @@
"""Tests for the Remember The Milk component."""
import logging
import unittest
from unittest.mock import patch, mock_open, Mock
import homeassistant.components.remember_the_milk as rtm
from tests.common import get_test_home_assistant
_LOGGER = logging.getLogger(__name__)
class TestConfiguration(unittest.TestCase):
"""Basic tests for the class RememberTheMilkConfiguration."""
def setUp(self):
"""Set up test home assistant main loop."""
self.hass = get_test_home_assistant()
self.profile = "myprofile"
self.token = "mytoken"
self.json_string = '{"myprofile": {"token": "mytoken"}}'
def tearDown(self):
"""Exit home assistant."""
self.hass.stop()
def test_create_new(self):
"""Test creating a new config file."""
with patch("builtins.open", mock_open()), \
patch("os.path.isfile", Mock(return_value=False)):
config = rtm.RememberTheMilkConfiguration(self.hass)
config.set_token(self.profile, self.token)
self.assertEqual(config.get_token(self.profile), self.token)
def test_load_config(self):
"""Test loading an existing token from the file."""
with patch("builtins.open", mock_open(read_data=self.json_string)), \
patch("os.path.isfile", Mock(return_value=True)):
config = rtm.RememberTheMilkConfiguration(self.hass)
self.assertEqual(config.get_token(self.profile), self.token)
def test_invalid_data(self):
"""Test starts with invalid data and should not raise an exception."""
with patch("builtins.open",
mock_open(read_data='random charachters')),\
patch("os.path.isfile", Mock(return_value=True)):
config = rtm.RememberTheMilkConfiguration(self.hass)
self.assertIsNotNone(config)