From 648aa5f969397da982a974bccd43f42407e35967 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2020 04:24:10 +0000 Subject: [PATCH] Auto convert xml, change out the template for jsonpath --- homeassistant/components/rest/manifest.json | 2 +- homeassistant/components/rest/sensor.py | 41 +++---- requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/rest/test_sensor.py | 118 +++++++++++--------- 5 files changed, 81 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index d03dce3017b..fd7eea12f7e 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -2,7 +2,7 @@ "domain": "rest", "name": "RESTful", "documentation": "https://www.home-assistant.io/integrations/rest", - "requirements": ["xmltodict==0.12.0"], + "requirements": ["jsonpath==0.82", "xmltodict==0.12.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index bdff22d5a3b..9372d76cd8a 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -1,9 +1,9 @@ """Support for RESTful API sensors.""" -import ast import json import logging from xml.parsers.expat import ExpatError +from jsonpath import jsonpath import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol @@ -41,11 +41,9 @@ DEFAULT_VERIFY_SSL = True DEFAULT_FORCE_UPDATE = False DEFAULT_TIMEOUT = 10 -CONF_CONVERT_XML = "convert_xml" -DEFAULT_CONVERT_XML = False CONF_JSON_ATTRS = "json_attributes" -CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" +CONF_JSON_ATTRS_PATH = "json_attributes_path" METHODS = ["POST", "GET"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -57,7 +55,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ), vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, - vol.Optional(CONF_CONVERT_XML, default=DEFAULT_CONVERT_XML): cv.boolean, vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, @@ -65,7 +62,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template, + vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, @@ -93,14 +90,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device_class = config.get(CONF_DEVICE_CLASS) value_template = config.get(CONF_VALUE_TEMPLATE) json_attrs = config.get(CONF_JSON_ATTRS) - convert_xml = config.get(CONF_CONVERT_XML) - json_attrs_template = config.get(CONF_JSON_ATTRS_TEMPLATE) + json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) force_update = config.get(CONF_FORCE_UPDATE) timeout = config.get(CONF_TIMEOUT) - if json_attrs_template is not None: - json_attrs_template.hass = hass - if value_template is not None: value_template.hass = hass @@ -134,8 +127,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): json_attrs, force_update, resource_template, - convert_xml, - json_attrs_template, + json_attrs_path, ) ], True, @@ -156,8 +148,7 @@ class RestSensor(Entity): json_attrs, force_update, resource_template, - convert_xml, - json_attrs_template, + json_attrs_path, ): """Initialize the REST sensor.""" self._hass = hass @@ -171,8 +162,7 @@ class RestSensor(Entity): self._attributes = None self._force_update = force_update self._resource_template = resource_template - self._convert_xml = convert_xml - self._json_attrs_template = json_attrs_template + self._json_attrs_path = json_attrs_path @property def name(self): @@ -211,9 +201,9 @@ class RestSensor(Entity): self.rest.update() value = self.rest.data - json_dict = None + content_type = self.rest.headers.get("content-type") - if self._convert_xml: + if content_type and content_type.startswith("text/xml"): try: value = json.dumps(xmltodict.parse(value)) except ExpatError: @@ -227,14 +217,8 @@ class RestSensor(Entity): if value: try: json_dict = json.loads(value) - if self._json_attrs_template is not None: - # render_with_possible_json_value returns single quoted - # strings so we cannot use json.loads to read it back here - json_dict = ast.literal_eval( - self._json_attrs_template.render_with_possible_json_value( - value, None - ) - ) + if self._json_attrs_path is not None: + json_dict = jsonpath(json_dict, self._json_attrs_path) if isinstance(json_dict, list): json_dict = json_dict[0] if isinstance(json_dict, dict): @@ -278,6 +262,7 @@ class RestData: self._verify_ssl = verify_ssl self._timeout = timeout self.data = None + self.headers = None def set_url(self, url): """Set url.""" @@ -297,6 +282,8 @@ class RestData: verify=self._verify_ssl, ) self.data = response.text + self.headers = response.headers except requests.exceptions.RequestException as ex: _LOGGER.error("Error fetching data: %s failed with %s", self._resource, ex) self.data = None + self.headers = None diff --git a/requirements_all.txt b/requirements_all.txt index f30de81f153..3cc7163d4f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -742,6 +742,7 @@ iperf3==0.1.11 # homeassistant.components.route53 ipify==1.0.0 +# homeassistant.components.rest # homeassistant.components.verisure jsonpath==0.82 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c6282cf4dc..fafea1c49ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -278,6 +278,7 @@ iaqualink==0.3.1 # homeassistant.components.influxdb influxdb==5.2.3 +# homeassistant.components.rest # homeassistant.components.verisure jsonpath==0.82 diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index d2ce631b4db..30eeae9a8e3 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -6,6 +6,7 @@ import pytest from pytest import raises import requests from requests.exceptions import RequestException, Timeout +from requests.structures import CaseInsensitiveDict import requests_mock import homeassistant.components.rest.sensor as rest @@ -184,7 +185,6 @@ class TestRestSensorSetup(unittest.TestCase): "unit_of_measurement": DATA_MEGABYTES, "verify_ssl": "true", "timeout": 30, - "convert_xml": True, "authentication": "basic", "username": "my username", "password": "my password", @@ -206,19 +206,18 @@ class TestRestSensor(unittest.TestCase): self.rest.update = Mock( "rest.RestData.update", side_effect=self.update_side_effect( - '{ "key": "' + self.initial_state + '" }' + '{ "key": "' + self.initial_state + '" }', + CaseInsensitiveDict({"Content-Type": "application/json"}), ), ) self.name = "foo" self.unit_of_measurement = DATA_MEGABYTES self.device_class = None self.value_template = template("{{ value_json.key }}") - self.json_attrs_template = template("{{ value_json }}") + self.json_attrs_path = None self.value_template.hass = self.hass - self.json_attrs_template.hass = self.hass self.force_update = False self.resource_template = None - self.convert_xml = False self.sensor = rest.RestSensor( self.hass, @@ -230,17 +229,17 @@ class TestRestSensor(unittest.TestCase): [], self.force_update, self.resource_template, - self.convert_xml, - self.json_attrs_template, + self.json_attrs_path, ) def tearDown(self): """Stop everything that was started.""" self.hass.stop() - def update_side_effect(self, data): + def update_side_effect(self, data, headers): """Side effect function for mocking RestData.update().""" self.rest.data = data + self.rest.headers = headers def test_name(self): """Test the name.""" @@ -262,7 +261,8 @@ class TestRestSensor(unittest.TestCase): def test_update_when_value_is_none(self): """Test state gets updated to unknown when sensor returns no data.""" self.rest.update = Mock( - "rest.RestData.update", side_effect=self.update_side_effect(None) + "rest.RestData.update", + side_effect=self.update_side_effect(None, CaseInsensitiveDict()), ) self.sensor.update() assert self.sensor.state is None @@ -272,7 +272,10 @@ class TestRestSensor(unittest.TestCase): """Test state gets updated when sensor returns a new status.""" self.rest.update = Mock( "rest.RestData.update", - side_effect=self.update_side_effect('{ "key": "updated_state" }'), + side_effect=self.update_side_effect( + '{ "key": "updated_state" }', + CaseInsensitiveDict({"Content-Type": "application/json"}), + ), ) self.sensor.update() assert "updated_state" == self.sensor.state @@ -281,7 +284,10 @@ class TestRestSensor(unittest.TestCase): def test_update_with_no_template(self): """Test update when there is no value template.""" self.rest.update = Mock( - "rest.RestData.update", side_effect=self.update_side_effect("plain_state") + "rest.RestData.update", + side_effect=self.update_side_effect( + "plain_state", CaseInsensitiveDict({"Content-Type": "application/json"}) + ), ) self.sensor = rest.RestSensor( self.hass, @@ -293,8 +299,7 @@ class TestRestSensor(unittest.TestCase): [], self.force_update, self.resource_template, - self.convert_xml, - self.json_attrs_template, + self.json_attrs_path, ) self.sensor.update() assert "plain_state" == self.sensor.state @@ -304,7 +309,10 @@ class TestRestSensor(unittest.TestCase): """Test attributes get extracted from a JSON result.""" self.rest.update = Mock( "rest.RestData.update", - side_effect=self.update_side_effect('{ "key": "some_json_value" }'), + side_effect=self.update_side_effect( + '{ "key": "some_json_value" }', + CaseInsensitiveDict({"Content-Type": "application/json"}), + ), ) self.sensor = rest.RestSensor( self.hass, @@ -316,8 +324,7 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, - self.convert_xml, - self.json_attrs_template, + self.json_attrs_path, ) self.sensor.update() assert "some_json_value" == self.sensor.device_state_attributes["key"] @@ -326,7 +333,10 @@ class TestRestSensor(unittest.TestCase): """Test attributes get extracted from a JSON list[0] result.""" self.rest.update = Mock( "rest.RestData.update", - side_effect=self.update_side_effect('[{ "key": "another_value" }]'), + side_effect=self.update_side_effect( + '[{ "key": "another_value" }]', + CaseInsensitiveDict({"Content-Type": "application/json"}), + ), ) self.sensor = rest.RestSensor( self.hass, @@ -338,8 +348,7 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, - self.convert_xml, - self.json_attrs_template, + self.json_attrs_path, ) self.sensor.update() assert "another_value" == self.sensor.device_state_attributes["key"] @@ -348,7 +357,10 @@ class TestRestSensor(unittest.TestCase): def test_update_with_json_attrs_no_data(self, mock_logger): """Test attributes when no JSON result fetched.""" self.rest.update = Mock( - "rest.RestData.update", side_effect=self.update_side_effect(None) + "rest.RestData.update", + side_effect=self.update_side_effect( + None, CaseInsensitiveDict({"Content-Type": "application/json"}) + ), ) self.sensor = rest.RestSensor( self.hass, @@ -360,8 +372,7 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, - self.convert_xml, - self.json_attrs_template, + self.json_attrs_path, ) self.sensor.update() assert {} == self.sensor.device_state_attributes @@ -372,7 +383,10 @@ class TestRestSensor(unittest.TestCase): """Test attributes get extracted from a JSON result.""" self.rest.update = Mock( "rest.RestData.update", - side_effect=self.update_side_effect('["list", "of", "things"]'), + side_effect=self.update_side_effect( + '["list", "of", "things"]', + CaseInsensitiveDict({"Content-Type": "application/json"}), + ), ) self.sensor = rest.RestSensor( self.hass, @@ -384,8 +398,7 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, - self.convert_xml, - self.json_attrs_template, + self.json_attrs_path, ) self.sensor.update() assert {} == self.sensor.device_state_attributes @@ -396,7 +409,10 @@ class TestRestSensor(unittest.TestCase): """Test attributes get extracted from a JSON result.""" self.rest.update = Mock( "rest.RestData.update", - side_effect=self.update_side_effect("This is text rather than JSON data."), + side_effect=self.update_side_effect( + "This is text rather than JSON data.", + CaseInsensitiveDict({"Content-Type": "text/plain"}), + ), ) self.sensor = rest.RestSensor( self.hass, @@ -408,8 +424,7 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, - self.convert_xml, - self.json_attrs_template, + self.json_attrs_path, ) self.sensor.update() assert {} == self.sensor.device_state_attributes @@ -421,7 +436,8 @@ class TestRestSensor(unittest.TestCase): self.rest.update = Mock( "rest.RestData.update", side_effect=self.update_side_effect( - '{ "key": "json_state_updated_value" }' + '{ "key": "json_state_updated_value" }', + CaseInsensitiveDict({"Content-Type": "application/json"}), ), ) self.sensor = rest.RestSensor( @@ -434,8 +450,7 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, - self.convert_xml, - self.json_attrs_template, + self.json_attrs_path, ) self.sensor.update() @@ -444,17 +459,17 @@ class TestRestSensor(unittest.TestCase): "json_state_updated_value" == self.sensor.device_state_attributes["key"] ), self.force_update - def test_update_with_json_attrs_with_json_attrs_template(self): + def test_update_with_json_attrs_with_json_attrs_path(self): """Test attributes get extracted from a JSON result with a template for the attributes.""" - json_attrs_template = template("{{ value_json.toplevel.second_level }}") - json_attrs_template.hass = self.hass + json_attrs_path = "$.toplevel.second_level" value_template = template("{{ value_json.toplevel.master_value }}") value_template.hass = self.hass self.rest.update = Mock( "rest.RestData.update", side_effect=self.update_side_effect( - '{ "toplevel": {"master_value": "master", "second_level": {"some_json_key": "some_json_value", "some_json_key2": "some_json_value2" } } }' + '{ "toplevel": {"master_value": "master", "second_level": {"some_json_key": "some_json_value", "some_json_key2": "some_json_value2" } } }', + CaseInsensitiveDict({"Content-Type": "application/json"}), ), ) self.sensor = rest.RestSensor( @@ -467,8 +482,7 @@ class TestRestSensor(unittest.TestCase): ["some_json_key", "some_json_key2"], self.force_update, self.resource_template, - self.convert_xml, - json_attrs_template, + json_attrs_path, ) self.sensor.update() @@ -478,18 +492,17 @@ class TestRestSensor(unittest.TestCase): ) assert "master" == self.sensor.state - def test_update_with_xml_convert_json_attrs_with_json_attrs_template(self): + def test_update_with_xml_convert_json_attrs_with_json_attrs_path(self): """Test attributes get extracted from a JSON result that was converted from XML with a template for the attributes.""" - json_attrs_template = template("{{ value_json.toplevel.second_level }}") - json_attrs_template.hass = self.hass + json_attrs_path = "$.toplevel.second_level" value_template = template("{{ value_json.toplevel.master_value }}") value_template.hass = self.hass - convert_xml = True self.rest.update = Mock( "rest.RestData.update", side_effect=self.update_side_effect( - "mastersome_json_valuesome_json_value2" + "mastersome_json_valuesome_json_value2", + CaseInsensitiveDict({"Content-Type": "text/xml+svg"}), ), ) self.sensor = rest.RestSensor( @@ -502,8 +515,7 @@ class TestRestSensor(unittest.TestCase): ["some_json_key", "some_json_key2"], self.force_update, self.resource_template, - convert_xml, - json_attrs_template, + json_attrs_path, ) self.sensor.update() @@ -515,16 +527,15 @@ class TestRestSensor(unittest.TestCase): def test_update_with_xml_convert_json_attrs_with_jsonattr_template(self): """Test attributes get extracted from a JSON result that was converted from XML.""" - json_attrs_template = template("{{ value_json.response }}") - json_attrs_template.hass = self.hass + json_attrs_path = "$.response" value_template = template("{{ value_json.response.bss.wlan }}") value_template.hass = self.hass - convert_xml = True self.rest.update = Mock( "rest.RestData.update", side_effect=self.update_side_effect( - '01255648alexander000bogus000000000upupupup000x0XF0x0XF 0' + '01255648alexander000bogus000000000upupupup000x0XF0x0XF 0', + CaseInsensitiveDict({"Content-Type": "text/xml"}), ), ) self.sensor = rest.RestSensor( @@ -537,8 +548,7 @@ class TestRestSensor(unittest.TestCase): ["led0", "led1", "temp0", "time0", "ver"], self.force_update, self.resource_template, - convert_xml, - json_attrs_template, + json_attrs_path, ) self.sensor.update() @@ -554,11 +564,12 @@ class TestRestSensor(unittest.TestCase): """Test attributes get extracted from a XML result with bad xml.""" value_template = template("{{ value_json.toplevel.master_value }}") value_template.hass = self.hass - convert_xml = True self.rest.update = Mock( "rest.RestData.update", - side_effect=self.update_side_effect("this is not xml"), + side_effect=self.update_side_effect( + "this is not xml", CaseInsensitiveDict({"Content-Type": "text/xml"}) + ), ) self.sensor = rest.RestSensor( self.hass, @@ -570,8 +581,7 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, - convert_xml, - self.json_attrs_template, + self.json_attrs_path, ) self.sensor.update()