From 866db9f9cc04ed21ad715e0fb445482068126699 Mon Sep 17 00:00:00 2001 From: bartonip Date: Fri, 26 Jun 2020 04:49:52 +0000 Subject: [PATCH 1/6] Fixed filter not working for services that require URLEncoded filters --- pyodata/v2/service.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index 950173e8..abb4022c 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -14,6 +14,13 @@ from http.client import HTTPResponse from io import BytesIO +try: + # For Python 3.0 and later + from urllib.parse import quote +except ImportError: + # Fallback to urllib2 + from urllib2 import quote + from pyodata.exceptions import HttpError, PyODataException, ExpressionError from . import model @@ -592,7 +599,7 @@ def expand(self, expand): def filter(self, filter_val): """Sets the filter expression.""" # returns QueryRequest - self._filter = filter_val + self._filter = quote(filter_val) return self # def nav(self, key_value, nav_property): From 87cfac9815782e03466387edf0d137e02a148685 Mon Sep 17 00:00:00 2001 From: bartonip Date: Fri, 26 Jun 2020 10:49:54 +0000 Subject: [PATCH 2/6] Added additional operators to GetEntitySetFilter --- pyodata/v2/service.py | 12 +++++++++++ tests/test_service_v2.py | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index abb4022c..b22cf0c7 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -956,6 +956,18 @@ def __eq__(self, value): def __ne__(self, value): return GetEntitySetFilter.format_filter(self._proprty, 'ne', value) + def __lt__(self, value): + return GetEntitySetFilter.format_filter(self._proprty, 'lt', value) + + def __le__(self, value): + return GetEntitySetFilter.format_filter(self._proprty, 'le', value) + + def __ge__(self, value): + return GetEntitySetFilter.format_filter(self._proprty, 'ge', value) + + def __gt__(self, value): + return GetEntitySetFilter.format_filter(self._proprty, 'gt', value) + class GetEntitySetRequest(QueryRequest): """GET on EntitySet""" diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 622952dd..699fbd8a 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -1332,6 +1332,50 @@ def test_get_entity_set_query_filter_ne(service): assert filter_str == "Key ne 'bar'" +def test_get_entity_set_query_filter_lt(service): + """Test the operator 'lt' of $filter for humans""" + + # pylint: disable=redefined-outer-name, invalid-name + + request = service.entity_sets.Cars.get_entities() + filter_str = request.Price < 2 + + assert filter_str == "Price lt 2" + + +def test_get_entity_set_query_filter_le(service): + """Test the operator 'le' of $filter for humans""" + + # pylint: disable=redefined-outer-name, invalid-name + + request = service.entity_sets.Cars.get_entities() + filter_str = request.Price <= 2 + + assert filter_str == "Price le 2" + + +def test_get_entity_set_query_filter_ge(service): + """Test the operator 'ge' of $filter for humans""" + + # pylint: disable=redefined-outer-name, invalid-name + + request = service.entity_sets.Cars.get_entities() + filter_str = request.Price >= 2 + + assert filter_str == "Price ge 2" + + +def test_get_entity_set_query_filter_gt(service): + """Test the operator 'gt' of $filter for humans""" + + # pylint: disable=redefined-outer-name, invalid-name + + request = service.entity_sets.Cars.get_entities() + filter_str = request.Price > 2 + + assert filter_str == "Price gt 2" + + def test_get_entity_set_query_filter_and(service): """Test the operator 'and' of $filter for humans""" From 06a52b8f4a4ee131c57f6424c0fe2f09b22b35da Mon Sep 17 00:00:00 2001 From: bartonip Date: Fri, 26 Jun 2020 11:23:53 +0000 Subject: [PATCH 3/6] Made headers attribute actually affect the headers --- pyodata/v2/service.py | 16 ++++++------ tests/test_service_v2.py | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index b22cf0c7..7239d738 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -235,11 +235,11 @@ def __repr__(self): class ODataHttpRequest: """Deferred HTTP Request""" - def __init__(self, url, connection, handler, headers=None): + def __init__(self, url, connection, handler, headers={}): self._connection = connection self._url = url self._handler = handler - self._headers = headers + self.headers = headers self._logger = logging.getLogger(LOGGER_NAME) @property @@ -284,7 +284,7 @@ def execute(self): # pylint: disable=assignment-from-none body = self.get_body() - headers = {} if self._headers is None else self._headers + headers = self.headers # pylint: disable=assignment-from-none extra_headers = self.get_headers() @@ -351,7 +351,7 @@ def get_path(self): return self._entity_set_proxy.last_segment + self._entity_key.to_key_string() def get_headers(self): - return {'Accept': 'application/json'} + return {'Accept': 'application/json', **self.headers} def get_query_params(self): qparams = super(EntityGetRequest, self).get_query_params() @@ -448,7 +448,7 @@ def get_body(self): return json.dumps(self._get_body()) def get_headers(self): - return {'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'X'} + return {'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'X', **self.headers} @staticmethod def _build_values(entity_type, entity): @@ -541,7 +541,7 @@ def get_body(self): return json.dumps(body) def get_headers(self): - return {'Accept': 'application/json', 'Content-Type': 'application/json'} + return {'Accept': 'application/json', 'Content-Type': 'application/json', **self.headers} def set(self, **kwargs): """Set properties to be changed.""" @@ -639,6 +639,7 @@ def get_headers(self): return { 'Accept': 'application/json', + **self.headers } def get_query_params(self): @@ -699,6 +700,7 @@ def get_method(self): def get_headers(self): return { 'Accept': 'application/json', + **self.headers } @@ -1445,7 +1447,7 @@ def get_boundary(self): def get_headers(self): # pylint: disable=no-self-use - return {'Content-Type': 'multipart/mixed;boundary={}'.format(self.get_boundary())} + return {'Content-Type': 'multipart/mixed;boundary={}'.format(self.get_boundary()), **self.headers} def get_body(self): return encode_multipart(self.get_boundary(), self.requests) diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 699fbd8a..5c535198 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -671,6 +671,59 @@ def test_get_entity_with_entity_key_and_other_params(service): query = service.entity_sets.TemperatureMeasurements.update_entity(key=key, Foo='Bar') assert query.get_path() == "TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')" + +def test_get_entities_with_custom_headers(service): + query = service.entity_sets.TemperatureMeasurements.get_entities() + query.headers = {"X-Foo": "bar"} + + assert query.get_headers() == {"Accept": "application/json", "X-Foo": "bar"} + + +def test_get_entity_with_custom_headers(service): + key = EntityKey( + service.schema.entity_type('TemperatureMeasurement'), + Sensor='sensor1', + Date=datetime.datetime(2017, 12, 24, 18, 0)) + + query = service.entity_sets.TemperatureMeasurements.get_entity(key) + query.headers = {"X-Foo": "bar"} + + assert query.get_headers() == {"Accept": "application/json", "X-Foo": "bar"} + + +def test_update_entities_with_custom_headers(service): + key = EntityKey( + service.schema.entity_type('TemperatureMeasurement'), + Sensor='sensor1', + Date=datetime.datetime(2017, 12, 24, 18, 0)) + + query = service.entity_sets.TemperatureMeasurements.update_entity(key) + query.headers = {"X-Foo": "bar"} + + assert query.get_headers() == {"Accept": "application/json", "Content-Type": "application/json", "X-Foo": "bar"} + + +def test_create_entity_with_custom_headers(service): + query = service.entity_sets.TemperatureMeasurements.create_entity() + query.headers = {"X-Foo": "bar"} + + assert query.get_headers() == {"Accept": "application/json", "Content-Type": "application/json", "X-Requested-With": "X", "X-Foo": "bar"} + + +def test_create_entity_with_overwriting_custom_headers(service): + query = service.entity_sets.TemperatureMeasurements.create_entity() + query.headers = {"X-Requested-With": "bar"} + + assert query.get_headers() == {"Accept": "application/json", "Content-Type": "application/json", "X-Requested-With": "bar"} + + +def test_create_entity_with_blank_custom_headers(service): + query = service.entity_sets.TemperatureMeasurements.create_entity() + query.headers = {} + + assert query.get_headers() == {"Accept": "application/json", "Content-Type": "application/json", "X-Requested-With": "X"} + + @responses.activate def test_get_entities(service): """Get entities""" From 95ad17f0d3150670c3f93ae800eca37465eaf7ef Mon Sep 17 00:00:00 2001 From: bartonip Date: Fri, 26 Jun 2020 11:47:07 +0000 Subject: [PATCH 4/6] Allow method to be specified in EntityModifyRequest --- pyodata/v2/service.py | 13 ++++++--- tests/test_service_v2.py | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index 7239d738..556b3511 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -512,13 +512,18 @@ class EntityModifyRequest(ODataHttpRequest): Call execute() to send the update-request to the OData service and get the modified entity.""" - def __init__(self, url, connection, handler, entity_set, entity_key): + def __init__(self, url, connection, handler, entity_set, entity_key, method="PATCH"): super(EntityModifyRequest, self).__init__(url, connection, handler) self._logger = logging.getLogger(LOGGER_NAME) self._entity_set = entity_set self._entity_type = entity_set.entity_type self._entity_key = entity_key + if method.upper() not in ["PATCH", "PUT"]: + raise ValueError("Method must be either PATCH or PUT") + + self._method = method + self._values = {} # get all properties declared by entity type @@ -531,7 +536,7 @@ def get_path(self): def get_method(self): # pylint: disable=no-self-use - return 'PATCH' + return self._method def get_body(self): # pylint: disable=no-self-use @@ -1154,7 +1159,7 @@ def create_entity_handler(response): return EntityCreateRequest(self._service.url, self._service.connection, create_entity_handler, self._entity_set, self.last_segment) - def update_entity(self, key=None, **kwargs): + def update_entity(self, key=None, method="PATCH", **kwargs): """Updates an existing entity in the given entity-set.""" def update_entity_handler(response): @@ -1172,7 +1177,7 @@ def update_entity_handler(response): self._logger.info('Updating entity %s for key %s and args %s', self._entity_set.entity_type.name, key, kwargs) return EntityModifyRequest(self._service.url, self._service.connection, update_entity_handler, self._entity_set, - entity_key) + entity_key, method=method) def delete_entity(self, key: EntityKey = None, **kwargs): """Delete the entity""" diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 5c535198..3caf3e61 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -658,6 +658,66 @@ def test_update_entity_with_entity_key(service): assert query.get_path() == "TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')" +def test_update_entity_with_put_method_specified(service): + """Make sure the method update_entity handles correctly when PUT method is specified""" + + # pylint: disable=redefined-outer-name + + + key = EntityKey( + service.schema.entity_type('TemperatureMeasurement'), + Sensor='sensor1', + Date=datetime.datetime(2017, 12, 24, 18, 0)) + + query = service.entity_sets.TemperatureMeasurements.update_entity(key, method="PUT") + assert query.get_method() == "PUT" + + +def test_update_entity_with_patch_method_specified(service): + """Make sure the method update_entity handles correctly when PATCH method is specified""" + + # pylint: disable=redefined-outer-name + + + key = EntityKey( + service.schema.entity_type('TemperatureMeasurement'), + Sensor='sensor1', + Date=datetime.datetime(2017, 12, 24, 18, 0)) + + query = service.entity_sets.TemperatureMeasurements.update_entity(key, method="PATCH") + assert query.get_method() == "PATCH" + + +def test_update_entity_with_no_method_specified(service): + """Make sure the method update_entity handles correctly when no method is specified""" + + # pylint: disable=redefined-outer-name + + + key = EntityKey( + service.schema.entity_type('TemperatureMeasurement'), + Sensor='sensor1', + Date=datetime.datetime(2017, 12, 24, 18, 0)) + + query = service.entity_sets.TemperatureMeasurements.update_entity(key) + assert query.get_method() == "PATCH" + + +def test_update_entity_with_wrong_method_specified(service): + """Make sure the method update_entity raises ValueError when wrong method is specified""" + + # pylint: disable=redefined-outer-name + + + key = EntityKey( + service.schema.entity_type('TemperatureMeasurement'), + Sensor='sensor1', + Date=datetime.datetime(2017, 12, 24, 18, 0)) + + with pytest.raises(ValueError): + service.entity_sets.TemperatureMeasurements.update_entity(key, method="DELETE") + + def test_get_entity_with_entity_key_and_other_params(service): """Make sure the method update_entity handles correctly the parameter key which is EntityKey""" From 3f98d274f1be386a697c2d54b4a020b192a513a0 Mon Sep 17 00:00:00 2001 From: Barton Ip Date: Fri, 26 Jun 2020 21:56:26 +1000 Subject: [PATCH 5/6] Update CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33f33802..981a82fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Specify PATCH or PUT method for EntityUpdateRequest - Barton Ip +- <, <=, >, >= operators on GetEntitySetFilter - Barton Ip + ### Fixed - URL encode $filter contents - Barton Ip +- Headers attribute on ODataHttpRequest - Barton Ip ## [1.5.0] From b6351d4908265b4667f6b733278e58cc3ddec023 Mon Sep 17 00:00:00 2001 From: bartonip Date: Sun, 28 Jun 2020 03:00:52 +0000 Subject: [PATCH 6/6] Fixed custom headers implementation as per #111 --- pyodata/v2/service.py | 24 +++++++++++++++--------- tests/test_service_v2.py | 20 ++++++++++++++------ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index 556b3511..e703ff70 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -235,11 +235,11 @@ def __repr__(self): class ODataHttpRequest: """Deferred HTTP Request""" - def __init__(self, url, connection, handler, headers={}): + def __init__(self, url, connection, handler, headers=None): self._connection = connection self._url = url self._handler = handler - self.headers = headers + self._headers = headers or dict() self._logger = logging.getLogger(LOGGER_NAME) @property @@ -272,6 +272,12 @@ def get_headers(self): # pylint: disable=no-self-use return None + def add_headers(self, value): + if not isinstance(value, dict): + raise TypeError("Headers must be of type 'dict' not {}".format(type(value))) + + self._headers.update(value) + def execute(self): """Fetches HTTP response and returns processed result @@ -284,7 +290,7 @@ def execute(self): # pylint: disable=assignment-from-none body = self.get_body() - headers = self.headers + headers = self._headers # pylint: disable=assignment-from-none extra_headers = self.get_headers() @@ -351,7 +357,7 @@ def get_path(self): return self._entity_set_proxy.last_segment + self._entity_key.to_key_string() def get_headers(self): - return {'Accept': 'application/json', **self.headers} + return {'Accept': 'application/json', **self._headers} def get_query_params(self): qparams = super(EntityGetRequest, self).get_query_params() @@ -448,7 +454,7 @@ def get_body(self): return json.dumps(self._get_body()) def get_headers(self): - return {'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'X', **self.headers} + return {'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'X', **self._headers} @staticmethod def _build_values(entity_type, entity): @@ -546,7 +552,7 @@ def get_body(self): return json.dumps(body) def get_headers(self): - return {'Accept': 'application/json', 'Content-Type': 'application/json', **self.headers} + return {'Accept': 'application/json', 'Content-Type': 'application/json', **self._headers} def set(self, **kwargs): """Set properties to be changed.""" @@ -644,7 +650,7 @@ def get_headers(self): return { 'Accept': 'application/json', - **self.headers + **self._headers } def get_query_params(self): @@ -705,7 +711,7 @@ def get_method(self): def get_headers(self): return { 'Accept': 'application/json', - **self.headers + **self._headers } @@ -1452,7 +1458,7 @@ def get_boundary(self): def get_headers(self): # pylint: disable=no-self-use - return {'Content-Type': 'multipart/mixed;boundary={}'.format(self.get_boundary()), **self.headers} + return {'Content-Type': 'multipart/mixed;boundary={}'.format(self.get_boundary()), **self._headers} def get_body(self): return encode_multipart(self.get_boundary(), self.requests) diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 3caf3e61..cc0e1b21 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -734,7 +734,7 @@ def test_get_entity_with_entity_key_and_other_params(service): def test_get_entities_with_custom_headers(service): query = service.entity_sets.TemperatureMeasurements.get_entities() - query.headers = {"X-Foo": "bar"} + query.add_headers({"X-Foo": "bar"}) assert query.get_headers() == {"Accept": "application/json", "X-Foo": "bar"} @@ -746,7 +746,7 @@ def test_get_entity_with_custom_headers(service): Date=datetime.datetime(2017, 12, 24, 18, 0)) query = service.entity_sets.TemperatureMeasurements.get_entity(key) - query.headers = {"X-Foo": "bar"} + query.add_headers({"X-Foo": "bar"}) assert query.get_headers() == {"Accept": "application/json", "X-Foo": "bar"} @@ -758,32 +758,40 @@ def test_update_entities_with_custom_headers(service): Date=datetime.datetime(2017, 12, 24, 18, 0)) query = service.entity_sets.TemperatureMeasurements.update_entity(key) - query.headers = {"X-Foo": "bar"} + query.add_headers({"X-Foo": "bar"}) assert query.get_headers() == {"Accept": "application/json", "Content-Type": "application/json", "X-Foo": "bar"} def test_create_entity_with_custom_headers(service): query = service.entity_sets.TemperatureMeasurements.create_entity() - query.headers = {"X-Foo": "bar"} + query.add_headers({"X-Foo": "bar"}) assert query.get_headers() == {"Accept": "application/json", "Content-Type": "application/json", "X-Requested-With": "X", "X-Foo": "bar"} def test_create_entity_with_overwriting_custom_headers(service): query = service.entity_sets.TemperatureMeasurements.create_entity() - query.headers = {"X-Requested-With": "bar"} + query.add_headers({"X-Requested-With": "bar"}) assert query.get_headers() == {"Accept": "application/json", "Content-Type": "application/json", "X-Requested-With": "bar"} def test_create_entity_with_blank_custom_headers(service): query = service.entity_sets.TemperatureMeasurements.create_entity() - query.headers = {} + query.add_headers({}) assert query.get_headers() == {"Accept": "application/json", "Content-Type": "application/json", "X-Requested-With": "X"} +def test_pass_incorrect_header_type(service): + query = service.entity_sets.TemperatureMeasurements.create_entity() + + with pytest.raises(TypeError) as ex: + query.add_headers(69420) + assert str(ex) == "TypeError: Headers must be of type 'dict' not " + + @responses.activate def test_get_entities(service): """Get entities"""