diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a902002..4afb10b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +- Service: Encode paths with entity keys with option to disable it - linda-sap ## [1.10.1] diff --git a/docs/usage/deleting.rst b/docs/usage/deleting.rst index f683fbf1..9dc2124b 100644 --- a/docs/usage/deleting.rst +++ b/docs/usage/deleting.rst @@ -41,4 +41,16 @@ or by passing the EntityKey object key = EntityKey(service.schema.entity_type('Employee'), ID=23) request = service.entity_sets.Employees.delete_entity(key=key) - request.execute() \ No newline at end of file + request.execute() + + +Encode OData URL Path +------------------------------------------- + +By default the resource paths of requests are percent encoded. However if this is not what your API expects, +you can disable the encoding with the variable encode_path by setting it to False. + + +.. code-block:: python + + request = service.entity_sets.Employees.delete_entity(key=key, encode_path=False) \ No newline at end of file diff --git a/docs/usage/querying.rst b/docs/usage/querying.rst index 33c7952c..110b3f77 100644 --- a/docs/usage/querying.rst +++ b/docs/usage/querying.rst @@ -154,7 +154,15 @@ these parameters by the method `custom(name: str, value: str)`. employees = northwind.entity_sets.Employees.get_entities().custom('sap-client', '100').custom('$skiptoken', 'ABCD').top(10).execute() +Encode OData URL Path +------------------------------------------- + +By default the resource paths of requests are percent encoded. However if this is not what your API expects, +you can disable the encoding with the variable encode_path by setting it to False. + +.. code-block:: python + employee = northwind.entity_sets.Employees.get_entity(1, encode_path=False).execute() (Experimental) Query server-side paginations using the __next field ------------------------------------------------------------------- diff --git a/docs/usage/updating.rst b/docs/usage/updating.rst index 29829970..454e2c6e 100644 --- a/docs/usage/updating.rst +++ b/docs/usage/updating.rst @@ -34,3 +34,14 @@ then you can consider setting the default service's update method to *PUT*. .. code-block:: python northwind.config['http']['update_method'] = 'PUT' + +Encode OData URL Path +------------------------------------------- + +By default the resource paths of requests are percent encoded. However if this is not what your API expects, +you can disable the encoding with the variable encode_path by setting it to False. + + +.. code-block:: python + + update_request = northwind.entity_sets.Customers.update_entity(CustomerID='ALFKI', encode_path=False) \ No newline at end of file diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index e69ef55a..0da34632 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -13,7 +13,7 @@ from email.parser import Parser from http.client import HTTPResponse from io import BytesIO -from urllib.parse import urlencode +from urllib.parse import urlencode, quote from pyodata.exceptions import HttpError, PyODataException, ExpressionError, ProgramError @@ -371,7 +371,7 @@ def custom(self, name, value): class EntityGetRequest(ODataHttpRequest): """Used for GET operations of a single entity""" - def __init__(self, handler, entity_key, entity_set_proxy): + def __init__(self, handler, entity_key, entity_set_proxy, encode_path=True): super(EntityGetRequest, self).__init__(entity_set_proxy.service.url, entity_set_proxy.service.connection, handler) self._logger = logging.getLogger(LOGGER_NAME) @@ -379,6 +379,7 @@ def __init__(self, handler, entity_key, entity_set_proxy): self._entity_set_proxy = entity_set_proxy self._select = None self._expand = None + self._encode_path = encode_path self._logger.debug('New instance of EntityGetRequest for last segment: %s', self._entity_set_proxy.last_segment) @@ -403,6 +404,8 @@ def expand(self, expand): return self def get_path(self): + if self.get_encode_path(): + return quote(self._entity_set_proxy.last_segment + self._entity_key.to_key_string()) return self._entity_set_proxy.last_segment + self._entity_key.to_key_string() def get_default_headers(self): @@ -439,6 +442,10 @@ def stream_handler(response): connection, stream_handler) + def get_encode_path(self): + """Getter for encode path flag""" + return self._encode_path + class NavEntityGetRequest(EntityGetRequest): """Used for GET operations of a single entity accessed via a Navigation property""" @@ -545,22 +552,30 @@ def set(self, **kwargs): class EntityDeleteRequest(ODataHttpRequest): """Used for deleting entity (DELETE operations on a single entity)""" - def __init__(self, url, connection, handler, entity_set, entity_key): + def __init__(self, url, connection, handler, entity_set, entity_key, encode_path=True): super(EntityDeleteRequest, self).__init__(url, connection, handler) self._logger = logging.getLogger(LOGGER_NAME) self._entity_set = entity_set self._entity_key = entity_key + self._encode_path = encode_path self._logger.debug('New instance of EntityDeleteRequest for entity type: %s', entity_set.entity_type.name) def get_path(self): + if self.get_encode_path(): + return quote(self._entity_set.name + self._entity_key.to_key_string()) return self._entity_set.name + self._entity_key.to_key_string() + def get_encode_path(self): + """Getter for encode path flag""" + return self._encode_path + def get_method(self): # pylint: disable=no-self-use return 'DELETE' +# pylint: disable=too-many-instance-attributes class EntityModifyRequest(ODataHttpRequest): """Used for modyfing entities (UPDATE/MERGE operations on a single entity) @@ -569,12 +584,14 @@ class EntityModifyRequest(ODataHttpRequest): ALLOWED_HTTP_METHODS = ['PATCH', 'PUT', 'MERGE'] - def __init__(self, url, connection, handler, entity_set, entity_key, method="PATCH"): + # pylint: disable=too-many-arguments + def __init__(self, url, connection, handler, entity_set, entity_key, method="PATCH", encode_path=True): 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 + self._encode_path = encode_path self._method = method.upper() if self._method not in EntityModifyRequest.ALLOWED_HTTP_METHODS: @@ -589,6 +606,8 @@ def __init__(self, url, connection, handler, entity_set, entity_key, method="PAT self._logger.debug('New instance of EntityModifyRequest for entity type: %s', self._entity_type.name) def get_path(self): + if self.get_encode_path(): + return quote(self._entity_set.name + self._entity_key.to_key_string()) return self._entity_set.name + self._entity_key.to_key_string() def get_method(self): @@ -605,6 +624,10 @@ def get_body(self): def get_default_headers(self): return {'Accept': 'application/json', 'Content-Type': 'application/json'} + def get_encode_path(self): + """Getter for encode path flag""" + return self._encode_path + def set(self, **kwargs): """Set properties to be changed.""" @@ -1307,10 +1330,11 @@ def __str__(self): class GetEntitySetRequest(QueryRequest): """GET on EntitySet""" - def __init__(self, url, connection, handler, last_segment, entity_type): + def __init__(self, url, connection, handler, last_segment, entity_type, encode_path=True): super(GetEntitySetRequest, self).__init__(url, connection, handler, last_segment) self._entity_type = entity_type + self._encode_path = encode_path def __getattr__(self, name): proprty = self._entity_type.proprty(name) @@ -1329,6 +1353,20 @@ def filter(self, *args, **kwargs): return self + def get_path(self): + if self.get_encode_path(): + path = quote(self._last_segment) + else: + path = self._last_segment + + if self._count: + return urljoin(path, '/$count') + return path + + def get_encode_path(self): + """Getter for encode path flag""" + return self._encode_path + class ListWithTotalCount(list): """ @@ -1452,7 +1490,7 @@ def get_entity_handler(parent, nav_property, navigation_entity_set, response): self, nav_property) - def get_entity(self, key=None, **args): + def get_entity(self, key=None, encode_path=True, **args): """Get entity based on provided key properties""" def get_entity_handler(response): @@ -1474,9 +1512,9 @@ def get_entity_handler(response): self._logger.info('Getting entity %s for key %s and args %s', self._entity_set.entity_type.name, key, args) - return EntityGetRequest(get_entity_handler, entity_key, self) + return EntityGetRequest(get_entity_handler, entity_key, self, encode_path=encode_path) - def get_entities(self): + def get_entities(self, encode_path=True): """Get some, potentially all entities""" def get_entities_handler(response): @@ -1513,7 +1551,8 @@ def get_entities_handler(response): entity_set_name = self._alias if self._alias is not None else self._entity_set.name return GetEntitySetRequest(self._service.url, self._service.connection, get_entities_handler, - self._parent_last_segment + entity_set_name, self._entity_set.entity_type) + self._parent_last_segment + entity_set_name, self._entity_set.entity_type, + encode_path=encode_path) def create_entity(self, return_code=HTTP_CODE_CREATED): """Creates a new entity in the given entity-set.""" @@ -1533,7 +1572,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, method=None, **kwargs): + def update_entity(self, key=None, method=None, encode_path=True, **kwargs): """Updates an existing entity in the given entity-set.""" def update_entity_handler(response): @@ -1554,9 +1593,9 @@ def update_entity_handler(response): method = self._service.config['http']['update_method'] return EntityModifyRequest(self._service.url, self._service.connection, update_entity_handler, self._entity_set, - entity_key, method=method) + entity_key, method=method, encode_path=encode_path) - def delete_entity(self, key: EntityKey = None, **kwargs): + def delete_entity(self, key: EntityKey = None, encode_path=True, **kwargs): """Delete the entity""" def delete_entity_handler(response): @@ -1573,7 +1612,7 @@ def delete_entity_handler(response): entity_key = EntityKey(self._entity_set.entity_type, key, **kwargs) return EntityDeleteRequest(self._service.url, self._service.connection, delete_entity_handler, self._entity_set, - entity_key) + entity_key, encode_path=encode_path) # pylint: disable=too-few-public-methods diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 39769dc9..d4bf82ca 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -4,6 +4,7 @@ import responses import requests import pytest +from urllib.parse import quote from unittest.mock import patch import pyodata.v2.model @@ -143,9 +144,10 @@ def test_create_entity_nested(service): }}, status=201) + path = quote("Cars('Hadraplan')") responses.add( responses.GET, - f"{service.url}/Cars('Hadraplan')/IDPic/$value/", + f"{service.url}/{path}/IDPic/$value/", headers={'Content-type': 'application/jpeg'}, body='DEADBEEF', status=200) @@ -209,9 +211,10 @@ def test_get_entity_property(service): # pylint: disable=redefined-outer-name + path = quote("MasterEntities('12345')") responses.add( responses.GET, - f"{service.url}/MasterEntities('12345')", + f"{service.url}/{path}", headers={ 'ETag': 'W/\"J0FtZXJpY2FuIEFpcmxpbmVzJw==\"', 'Content-type': 'application/json', @@ -229,10 +232,10 @@ def test_entity_url(service): """Test correct build of entity url""" # pylint: disable=redefined-outer-name - + path = quote("MasterEntities('12345')") responses.add( responses.GET, - f"{service.url}/MasterEntities('12345')", + f"{service.url}/{path}", headers={'Content-type': 'application/json'}, json={'d': {'Key': '12345'}}, status=200) @@ -246,10 +249,10 @@ def test_entity_entity_set_name(service): """Test correct entity set name""" # pylint: disable=redefined-outer-name - + path = quote("MasterEntities('12345')") responses.add( responses.GET, - f"{service.url}/MasterEntities('12345')", + f"{service.url}/{path}", headers={'Content-type': 'application/json'}, json={'d': {'Key': '12345'}}, status=200) @@ -263,10 +266,10 @@ def test_entity_key_simple(service): """Test simple key of entity""" # pylint: disable=redefined-outer-name - + path = quote("MasterEntities('12345')") responses.add( responses.GET, - f"{service.url}/MasterEntities('12345')", + f"{service.url}/{path}", headers={'Content-type': 'application/json'}, json={'d': {'Key': '12345'}}, status=200) @@ -281,10 +284,10 @@ def test_entity_key_complex(service): """Test complex key of entity""" # pylint: disable=redefined-outer-name - + path = quote("TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')") responses.add( responses.GET, - f"{service.url}/TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')", + f"{service.url}/{path}", headers={'Content-type': 'application/json'}, json={'d': { 'Sensor': 'sensor1', @@ -611,10 +614,10 @@ def test_update_entity(service): """Check updating of entity properties""" # pylint: disable=redefined-outer-name - + path = quote("TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')") responses.add( responses.PATCH, - f"{service.url}/TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')", + f"{service.url}/{path}", json={'d': { 'Sensor': 'Sensor-address', 'Date': "/Date(1714138400000)/", @@ -647,18 +650,30 @@ def test_update_entity(service): def test_delete_entity(service): """Check deleting of entity""" - responses.add(responses.DELETE, f"{service.url}/Employees(23)", status=204) + path = quote("Employees(23)") + responses.add(responses.DELETE, f"{service.url}/{path}", status=204) request = service.entity_sets.Employees.delete_entity(23) assert isinstance(request, pyodata.v2.service.EntityDeleteRequest) assert request.execute() is None +@responses.activate +def test_delete_entity_not_encoded_path(service): + """Check deleting of entity""" + + responses.add(responses.DELETE, f"{service.url}/Employees(23)", status=204) + request = service.entity_sets.Employees.delete_entity(23, encode_path=False) + + assert isinstance(request, pyodata.v2.service.EntityDeleteRequest) + assert request.execute() is None + @responses.activate def test_delete_entity_with_key(service): """Check deleting of entity with key""" - responses.add(responses.DELETE, f"{service.url}/Employees(ID=23)", status=204) + path = quote("Employees(ID=23)") + responses.add(responses.DELETE, f"{service.url}/{path}", status=204) key = EntityKey(service.schema.entity_type('Employee'), ID=23) request = service.entity_sets.Employees.delete_entity(key=key) @@ -670,7 +685,8 @@ def test_delete_entity_with_key(service): def test_delete_entity_http_error(service): """Check if error is raisen when deleting unknown entity""" - responses.add(responses.DELETE, f"{service.url}/Employees(ID=23)", status=404) + path = quote("Employees(ID=23)") + responses.add(responses.DELETE, f"{service.url}/{path}", status=404) key = EntityKey(service.schema.entity_type('Employee'), ID=23) request = service.entity_sets.Employees.delete_entity(key=key) @@ -695,7 +711,7 @@ def test_update_entity_with_entity_key(service): Date=datetime.datetime(2017, 12, 24, 18, 0, tzinfo=datetime.timezone.utc)) query = service.entity_sets.TemperatureMeasurements.update_entity(key) - assert query.get_path() == "TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')" + assert query.get_path() == quote("TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')") def test_update_entity_with_put_method_specified(service): @@ -980,10 +996,10 @@ def test_navigation_multi(service): """Get entities via navigation property""" # pylint: disable=redefined-outer-name - + path = quote("Employees(23)/Addresses") responses.add( responses.GET, - f"{service.url}/Employees(23)/Addresses", + f"{service.url}/{path}", json={'d': { 'results': [ { @@ -1024,10 +1040,10 @@ def test_navigation(service): """Check getting entity via navigation property""" # pylint: disable=redefined-outer-name - + path = quote("Employees(23)/Addresses(456)") responses.add( responses.GET, - f"{service.url}/Employees(23)/Addresses(456)", + f"{service.url}/{path}", json={'d': { 'ID': 456, 'Street': 'Baker Street', @@ -1050,10 +1066,10 @@ def test_navigation_multi_on1(service): """Check getting entity via navigation property""" # pylint: disable=redefined-outer-name - + path = quote("Customers('Mammon')/ReferredBy") responses.add( responses.GET, - f"{service.url}/Customers('Mammon')/ReferredBy", + f"{service.url}/{path}", headers={'Content-type': 'application/json'}, json = { 'd': { 'Name': 'John', @@ -1078,10 +1094,10 @@ def test_navigation_1on1(service): """Check getting entity via navigation property""" # pylint: disable=redefined-outer-name - + path = quote("Cars('Hadraplan')") responses.add( responses.GET, - f"{service.url}/Cars('Hadraplan')/IDPic", + f"{service.url}/{path}/IDPic", headers={'Content-type': 'application/json'}, json = { 'd': { 'CarName': 'Hadraplan', @@ -1108,10 +1124,10 @@ def test_navigation_1on1_from_entity_proxy(service): """Check getting entity via navigation property""" # pylint: disable=redefined-outer-name - + path = quote("Cars('Hadraplan')") responses.add( responses.GET, - f"{service.url}/Cars('Hadraplan')", + f"{service.url}/{path}", headers={'Content-type': 'application/json'}, json = { 'd': { 'Name': 'Hadraplan', @@ -1121,7 +1137,7 @@ def test_navigation_1on1_from_entity_proxy(service): responses.add( responses.GET, - f"{service.url}/Cars('Hadraplan')/IDPic", + f"{service.url}/{path}/IDPic", headers={'Content-type': 'application/json'}, json = { 'd': { 'CarName': 'Hadraplan', @@ -1153,10 +1169,10 @@ def test_navigation_1on1_get_value_without_proxy(service): """Check getting $value via navigation property""" # pylint: disable=redefined-outer-name - + path = quote("Cars('Hadraplan')") responses.add( responses.GET, - f"{service.url}/Cars('Hadraplan')/IDPic/$value/", + f"{service.url}/{path}/IDPic/$value/", headers={'Content-type': 'application/jpeg'}, body='DEADBEAF', status=200) @@ -1175,10 +1191,10 @@ def test_navigation_when_nes_in_another_ns(service): """ # pylint: disable=redefined-outer-name - + path = quote("Customers('Mammon')/Orders") responses.add( responses.GET, - f"{service.url}/Customers('Mammon')/Orders", + f"{service.url}/{path}", json={'d': {'results' : [{ 'Number': '456', 'Owner': 'Mammon', @@ -1202,10 +1218,10 @@ def test_entity_get_value_1on1_with_proxy(service): """Check getting $value""" # pylint: disable=redefined-outer-name - + path = quote("Cars('Hadraplan')") responses.add( responses.GET, - f"{service.url}/Cars('Hadraplan')/IDPic", + f"{service.url}/{path}/IDPic", headers={'Content-type': 'application/json'}, json = { 'd': { 'CarName': 'Hadraplan', @@ -1233,10 +1249,10 @@ def test_entity_get_value_without_proxy(service): """Check getting $value without proxy""" # pylint: disable=redefined-outer-name - + path = quote("CarIDPics('Hadraplan')") responses.add( responses.GET, - f"{service.url}/CarIDPics('Hadraplan')/$value/", + f"{service.url}/{path}/$value/", headers={'Content-type': 'application/jpeg'}, body='DEADBEAF', status=200) @@ -1253,10 +1269,10 @@ def test_entity_get_value_with_proxy(service): """Check getting $value with proxy""" # pylint: disable=redefined-outer-name - + path = quote("CarIDPics('Hadraplan')") responses.add( responses.GET, - f"{service.url}/CarIDPics('Hadraplan')", + f"{service.url}/{path}", headers={'Content-type': 'application/json'}, json = { 'd': { 'CarName': 'Hadraplan', @@ -1284,10 +1300,10 @@ def test_entity_get_value_without_proxy_error(service): """Check getting $value without proxy""" # pylint: disable=redefined-outer-name - + path = quote("CarIDPics('Hadraplan')") responses.add( responses.GET, - f"{service.url}/CarIDPics('Hadraplan')/$value/", + f"{service.url}/{path}/$value/", headers={'Content-type': 'text/plain'}, body='Internal Server Error', status=500) @@ -1334,10 +1350,10 @@ def test_navigation_from_entity_multi(service): """Get entities via navigation property from entity proxy""" # pylint: disable=redefined-outer-name - + path = quote("Employees(23)") responses.add( responses.GET, - f"{service.url}/Employees(23)", + f"{service.url}/{path}", json={'d': { 'ID': 23, 'NameFirst': 'Rob', @@ -1347,7 +1363,7 @@ def test_navigation_from_entity_multi(service): responses.add( responses.GET, - f"{service.url}/Employees(23)/Addresses", + f"{service.url}/{path}/Addresses", json={'d': { 'results': [ { @@ -1394,10 +1410,10 @@ def test_navigation_from_entity(service): """Check getting entity via navigation property from entity proxy""" # pylint: disable=redefined-outer-name - + path_employes = quote("Employees(23)") responses.add( responses.GET, - f"{service.url}/Employees(23)", + f"{service.url}/{path_employes}", json={'d': { 'ID': 23, 'NameFirst': 'Rob', @@ -1405,9 +1421,10 @@ def test_navigation_from_entity(service): }}, status=200) + path_employes_adresses = quote("Employees(23)/Addresses(456)") responses.add( responses.GET, - f"{service.url}/Employees(23)/Addresses(456)", + f"{service.url}/{path_employes_adresses}", json={'d': { 'ID': 456, 'Street': 'Baker Street', @@ -1437,10 +1454,10 @@ def test_get_entity(service): """Check getting entities""" # pylint: disable=redefined-outer-name - + path = quote("Employees(23)") responses.add( responses.GET, - f"{service.url}/Employees(23)", + f"{service.url}/{path}", json={'d': { 'ID': 23, 'NameFirst': 'Rob', @@ -1457,16 +1474,42 @@ def test_get_entity(service): assert emp.NameFirst == 'Rob' assert emp.NameLast == 'Ickes' +@responses.activate +def test_get_entity_not_encoded_path(service): + """Check getting entities""" + + # pylint: disable=redefined-outer-name + + responses.add( + responses.GET, + f"{service.url}/TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')", + json={'d': { + 'Sensor': 'sensor1', + 'Date': '/Date(1514138400000)/', + 'Value': 34.0 + }}, + status=200) + + request = service.entity_sets.TemperatureMeasurements.get_entity(Sensor='sensor1', Date=datetime.datetime(2017, 12, 24, 18, 0, tzinfo=datetime.timezone.utc), encode_path=False) + + assert isinstance(request, pyodata.v2.service.EntityGetRequest) + assert request.get_path() == "TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')" + + emp = request.execute() + assert emp.Sensor == 'sensor1' + assert emp.Date == datetime.datetime(2017, 12, 24, 18, 0, tzinfo=datetime.timezone.utc) + assert emp.Value == 34.0 + @responses.activate def test_get_entity_expanded(service): """Check getting entities with expanded navigation properties""" # pylint: disable=redefined-outer-name - + path = quote("Employees(23)") responses.add( responses.GET, - f"{service.url}/Employees(23)", + f"{service.url}/{path}", json={'d': { 'ID': 23, 'NameFirst': 'Rob', @@ -1563,6 +1606,77 @@ def test_batch_request(service): assert len(chset_response) == 1 assert chset_response[0] is None # response to update request is None +@responses.activate +def test_batch_request_not_encoded_path(service): + """Batch requests""" + + # pylint: disable=redefined-outer-name + + response_body = (b'--batch_r1\n' + b'Content-Type: application/http\n' + b'Content-Transfer-Encoding: binary\n' + b'\n' + b'HTTP/1.1 200 OK\n' + b'Content-Type: application/json\n' + b'\n' + b'{"d": {"ID": 23, "NameFirst": "Rob", "NameLast": "Ickes", "Address": { "ID": 456, "Street": "Baker Street", "City": "London"} }}' + b'\n' + b'--batch_r1\n' + b'Content-Type: multipart/mixed; boundary=changeset_1\n' + b'\n' + b'--changeset_1\n' + b'Content-Type: application/http\n' + b'Content-Transfer-Encoding: binary\n' + b'\n' + b'HTTP/1.1 204 Updated\n' + b'Content-Type: application/json\n' + b'\n' + b"{b'd': {'Sensor': 'Sensor-address', 'Date': datetime\'2017-12-24T18:00\', 'Value': '34.0d'}}" + b'\n' + b'--changeset_1--\n' + b'\n' + b'--batch_r1--') + + responses.add( + responses.POST, + f'{URL_ROOT}/$batch', + body=response_body, + content_type='multipart/mixed; boundary=batch_r1', + status=202) + + batch = service.create_batch('batch1') + + chset = service.create_changeset('chset1') + + employee_request = service.entity_sets.Employees.get_entity(23, encode_path=False) + + assert employee_request.get_path() == 'Employees(23)' + + temp_request = service.entity_sets.TemperatureMeasurements.update_entity( + Sensor='sensor1', + Date=datetime.datetime(2017, 12, 24, 18, 0, tzinfo=datetime.timezone.utc), + encode_path=True).set(Value=34.0) + + batch.add_request(employee_request) + + chset.add_request(temp_request) + + assert f'{temp_request.get_method()} TemperatureMeasurements%28Sensor%3D%27sensor1%27%2CDate%3Ddatetime%272017-12-24T18%3A00%3A00%27%29' in chset.get_body() + + batch.add_request(chset) + batch.add_request(temp_request) + + response = batch.execute() + + assert len(response) == 2 + + employee_response = response[0] + assert isinstance(employee_response, pyodata.v2.service.EntityProxy) + + chset_response = response[1] + assert isinstance(chset_response, list) + assert len(chset_response) == 1 + assert chset_response[0] is None # response to update request is None @responses.activate def test_enormous_batch_request(service): @@ -1646,7 +1760,7 @@ def test_get_entity_with_entity_key(service): Date=datetime.datetime(2017, 12, 24, 18, 0, tzinfo=datetime.timezone.utc)) query = service.entity_sets.TemperatureMeasurements.get_entity(key) - assert query.get_path() == "TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')" + assert query.get_path() == quote("TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')") def test_get_entity_with_entity_key_and_other_params(service): @@ -1660,7 +1774,7 @@ def test_get_entity_with_entity_key_and_other_params(service): Date=datetime.datetime(2017, 12, 24, 18, 0, tzinfo=datetime.timezone.utc)) query = service.entity_sets.TemperatureMeasurements.get_entity(key=key, Foo='Bar') - assert query.get_path() == "TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')" + assert query.get_path() == quote("TemperatureMeasurements(Sensor='sensor1',Date=datetime'2017-12-24T18:00:00')") def test_entity_proxy_equals(service): @@ -1876,10 +1990,10 @@ def test_navigation_inlinecount(service): """Check getting entities with $inlinecount via navigation property""" # pylint: disable=redefined-outer-name - + path = quote("Employees(23)/Addresses") responses.add( responses.GET, - f"{service.url}/Employees(23)/Addresses?$inlinecount=allpages", + f"{service.url}/{path}?$inlinecount=allpages", json={'d': { '__count': 3, 'results': [ @@ -1913,10 +2027,10 @@ def test_inlinecount_with_filter(service): """Check getting entities with $inlinecount and $filter""" # pylint: disable=redefined-outer-name - + path = quote("Employees(23)/Addresses") responses.add( responses.GET, - f"{service.url}/Employees(23)/Addresses?$inlinecount=allpages&%24filter=City%20eq%20%27London%27", + f"{service.url}/{path}?$inlinecount=allpages&%24filter=City%20eq%20%27London%27", json={'d': { '__count': 2, 'results': [ @@ -2024,9 +2138,10 @@ def test_navigation_count(service): # pylint: disable=redefined-outer-name + path = quote("Employees(23)/Addresses") responses.add( responses.GET, - f"{service.url}/Employees(23)/Addresses/$count", + f"{service.url}/{path}/$count", json=458, status=200) @@ -2043,10 +2158,10 @@ def test_count_with_filter(service): """Check getting $count with $filter""" # pylint: disable=redefined-outer-name - + path = quote("Employees(23)/Addresses") responses.add( responses.GET, - f"{service.url}/Employees(23)/Addresses/$count?%24filter=City%20eq%20%27London%27", + f"{service.url}/{path}/$count?%24filter=City%20eq%20%27London%27", json=3, status=200) @@ -2063,10 +2178,10 @@ def test_count_with_chainable_filter(service): """Check getting $count with $filter and using new filter syntax""" # pylint: disable=redefined-outer-name - + path = quote("Employees(23)/Addresses") responses.add( responses.GET, - f"{service.url}/Employees(23)/Addresses/$count?%24filter=City%20eq%20%27London%27", + f"{service.url}/{path}/$count?%24filter=City%20eq%20%27London%27", json=3, status=200) @@ -2696,9 +2811,10 @@ def test_odata_http_response(): def test_custom_with_get_entity(service): """ Test that `custom` can be called after `get_entity`""" + path = quote("MasterEntities('12345')") responses.add( responses.GET, - f"{service.url}/MasterEntities('12345')?foo=bar", + f"{service.url}/{path}?foo=bar", headers={'Content-type': 'application/json'}, json={'d': {'Key': '12345'}}, status=200) @@ -2710,16 +2826,17 @@ def test_custom_with_get_entity(service): def test_custom_with_get_entity_url_params(service): """ Test that `custom` after `get_entity` is setting up correctly URL parts """ + path = quote("MasterEntities('12345')") responses.add( responses.GET, - f"{service.url}/MasterEntities('12345')?foo=bar", + f"{service.url}/{path}?foo=bar", headers={'Content-type': 'application/json'}, json={'d': {'Key': '12345'}}, status=200) oDataHttpRequest = service.entity_sets.MasterEntities.get_entity('12345').custom("foo", "bar") assert oDataHttpRequest.get_query_params() == {'foo': 'bar'} - assert oDataHttpRequest.get_path() == "MasterEntities('12345')" + assert oDataHttpRequest.get_path() == quote("MasterEntities('12345')") entity = oDataHttpRequest.execute() assert entity.Key == '12345' @@ -2728,16 +2845,17 @@ def test_custom_with_get_entity_url_params(service): def test_multiple_custom_with_get_entity_url_params(service): """ Test that `custom` after `get_entity` called several times is setting up correctly URL parts """ + path = quote("MasterEntities('12345')") responses.add( responses.GET, - f"{service.url}/MasterEntities('12345')?foo=bar&$fizz=buzz", + f"{service.url}/{path}?foo=bar&$fizz=buzz", headers={'Content-type': 'application/json'}, json={'d': {'Key': '12345'}}, status=200) oDataHttpRequest = service.entity_sets.MasterEntities.get_entity('12345').custom("foo", "bar").custom("$fizz", "buzz") assert oDataHttpRequest.get_query_params() == {'foo': 'bar', '$fizz': 'buzz'} - assert oDataHttpRequest.get_path() == "MasterEntities('12345')" + assert oDataHttpRequest.get_path() == quote("MasterEntities('12345')") entity = oDataHttpRequest.execute() assert entity.Key == '12345'