From 893d32104bcd4ad45a702ffd25946276024b05e1 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Mon, 29 Mar 2021 09:23:49 +0200 Subject: [PATCH 01/19] Fix error exception --- odata/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/connection.py b/odata/connection.py index 995557c..deefd27 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -88,7 +88,7 @@ def _handle_odata_error(self, response): ie = odata_error['innererror'] detailed_message = ie.get('message') or detailed_message - msg = ' | '.join([status_code, code, message, detailed_message]) + msg = ' | '.join([str(status_code), str(code), str(message), str(detailed_message)]) err = ODataError(msg) err.status_code = status_code err.code = code From 1ed10f4137a1f42dbb1017d2936cd3009eeedb5b Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Mon, 29 Mar 2021 09:24:46 +0200 Subject: [PATCH 02/19] Fix create empty values and fix for foreign keys --- odata/state.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/odata/state.py b/odata/state.py index c493c7e..461d82c 100644 --- a/odata/state.py +++ b/odata/state.py @@ -171,22 +171,24 @@ def _clean_new_entity(self, entity): if prop.is_computed_value: continue - insert_data[prop.name] = es[prop.name] + if es.data[prop.name] is not None: + insert_data[prop.name] = es.data[prop.name] # Allow pk properties only if they have values for _, pk_prop in es.primary_key_properties: - if insert_data[pk_prop.name] is None: + if pk_prop.name in insert_data and insert_data[pk_prop.name] is None: insert_data.pop(pk_prop.name) # Deep insert from nav properties for prop_name, prop in es.navigation_properties: - if prop.foreign_key: - insert_data.pop(prop.foreign_key, None) value = getattr(entity, prop_name, None) """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" if value is not None: + if prop.foreign_key: + insert_data.pop(prop.foreign_key, None) + if prop.is_collection: binds = [] From 810944e2d7774f01abd6dd213e485c243ae30dc1 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Mon, 29 Mar 2021 09:25:34 +0200 Subject: [PATCH 03/19] New mode for create entity from dict --- odata/entity.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/odata/entity.py b/odata/entity.py index feab6bb..af4909c 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -125,6 +125,26 @@ def __new__(cls, *args, **kwargs): return i + @classmethod + def create(cls, raw_data): + i = super(EntityBase, cls).__new__(cls) + i.__odata__ = es = EntityState(i) + + for prop_name, prop in es.navigation_properties: + if prop.name in raw_data: + expanded_data = raw_data.pop(prop.name) + if prop.is_collection: + es.nav_cache[prop.name] = dict(collection=prop.instances_from_data(expanded_data)) + else: + es.nav_cache[prop.name] = dict(single=prop.instances_from_data(expanded_data)) + + for prop_name, prop in es.properties: + i.__odata__[prop.name] = raw_data.get(prop.name) + + i.__odata__.persisted = False + + return i + def __repr__(self): clsname = self.__class__.__name__ display_string = self.__odata__.id or clsname From bcca2244d00f7be1fd69c259027caa25790cdea0 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Mon, 29 Mar 2021 09:29:09 +0200 Subject: [PATCH 04/19] fix metadata url --- odata/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/metadata.py b/odata/metadata.py index 5b76be8..c794d66 100644 --- a/odata/metadata.py +++ b/odata/metadata.py @@ -41,7 +41,7 @@ class MetaData(object): _annotation_term_computed = 'Org.OData.Core.V1.Computed' def __init__(self, service): - self.url = service.url + '$metadata/' + self.url = service.url + '$metadata' self.connection = service.default_context.connection self.service = service From 100f390142dd022423583b565e63f60ffcf8d14d Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Mon, 29 Mar 2021 09:46:23 +0200 Subject: [PATCH 05/19] Add utils library, sap session --- odata/utils.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 odata/utils.py diff --git a/odata/utils.py b/odata/utils.py new file mode 100644 index 0000000..5d75184 --- /dev/null +++ b/odata/utils.py @@ -0,0 +1,53 @@ +import requests +from requests.adapters import HTTPAdapter, Retry + +DEFAULT_TIMEOUT = 5 # seconds + + +class TimeoutHTTPAdapter(HTTPAdapter): + def __init__(self, *args, **kwargs): + self.timeout = DEFAULT_TIMEOUT + if "timeout" in kwargs: + self.timeout = kwargs["timeout"] + del kwargs["timeout"] + super().__init__(*args, **kwargs) + + def send(self, request, **kwargs): + timeout = kwargs.get("timeout") + if timeout is None: + kwargs["timeout"] = self.timeout + return super().send(request, **kwargs) + + +def retry_strategy(): + retry_strategy = Retry( + total=3, + status_forcelist=[429, 500, 502, 503, 504], + method_whitelist=["HEAD", "GET", "OPTIONS"] + ) + return retry_strategy + + +def get_sap_session(url, credentials, default_cookies=None): + """ + Get sap session with retry strategy + + :param url: + :param credentials: ("UserName", "Password", "CompanyDB") + :param default_cookies: + :return: + """ + adapter = TimeoutHTTPAdapter(max_retries=retry_strategy()) + session = requests.Session() + session.mount("https://", adapter) + session.mount("http://", adapter) + + if default_cookies is None: + default_cookies = {"ROUTEID": ".node1"} + + session.request( + 'POST', url + 'Login', json=credentials, cookies=default_cookies, + verify=False + ) + + return session From cd28da8fcba0ea64bc68c44adf8436a35d91356d Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Mon, 29 Mar 2021 11:52:18 +0200 Subject: [PATCH 06/19] fix functions bind --- odata/metadata.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/odata/metadata.py b/odata/metadata.py index c794d66..d5a552d 100644 --- a/odata/metadata.py +++ b/odata/metadata.py @@ -269,9 +269,10 @@ def _parse_action(self, xmlq, action_element, schema_name): # bound actions are named SchemaNamespace.ActionName action['fully_qualified_name'] = '.'.join([schema_name, action['name']]) - for parameter_element in xmlq(action_element, 'edm:Parameter'): + for i, parameter_element in enumerate(xmlq(action_element, 'edm:Parameter')): parameter_name = parameter_element.attrib['Name'] - if action['is_bound'] and parameter_name == 'bindingParameter': + + if i == 0: action['is_bound_to'] = parameter_element.attrib['Type'] continue From 9c1e1a031061ca527377d946819c454bb9fcbc79 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Mon, 29 Mar 2021 17:23:55 +0200 Subject: [PATCH 07/19] add config files --- .editorconfig | 35 +++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 .editorconfig create mode 100644 .pre-commit-config.yaml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bcae924 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ +; indicate this is the root of the project +root = true +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 + +[*.py] +max_line_length = 88 + +[{*.hcl,*.nomad}] +indent_size = 2 + +[{*.tfvars,*.tf}] +indent_size = 2 + +[{*.yml,*.yaml}] +indent_size = 2 + +[{.babelrc,.eslintrc,jest.config,.stylelintrc,bowerrc,*.json,*.jsb3,*.jsb2}] +indent_size = 2 + +[Makefile] +indent_size = 4 +indent_style = tab + +[Jenkinsfile] +indent_size = 2 + +[*.md] +max_line_length = 80 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..300f4fb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +--- +default_language_version: + python: python3.8 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.4.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + exclude: assets/* + - id: debug-statements + - id: no-commit-to-branch + - id: mixed-line-ending + args: [--fix=lf] + - id: detect-private-key + - id: detect-aws-credentials + args: [--allow-missing-credentials] + - id: check-merge-conflict + - id: requirements-txt-fixer + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.19.0 + hooks: + - id: markdownlint + language_version: system + + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + pass_filenames: false + args: [".", "--exclude", "migrations/*",] From 95ce6c7e925e5ccddc9000f6ac3c3c62a6020229 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Wed, 31 Mar 2021 09:00:50 +0200 Subject: [PATCH 08/19] Fix changes in collection not change to dirty --- odata/property.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/odata/property.py b/odata/property.py index 35814ed..adf395b 100644 --- a/odata/property.py +++ b/odata/property.py @@ -49,7 +49,7 @@ Types ----- """ - +import copy from decimal import Decimal import datetime @@ -98,7 +98,7 @@ def __get__(self, instance, owner): data = [] for i in raw_data: data.append(self.deserialize(i)) - return data + return copy.deepcopy(data) else: return self.deserialize(raw_data) else: From dc4f1b9d997722485d6512f033886f1076b96c01 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Wed, 31 Mar 2021 09:56:20 +0200 Subject: [PATCH 09/19] Extract raw metadata xml --- odata/metadata.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/odata/metadata.py b/odata/metadata.py index d5a552d..c15cf21 100644 --- a/odata/metadata.py +++ b/odata/metadata.py @@ -249,9 +249,11 @@ def get_entity_or_prop_from_type(typename): self.log.info('Loaded {0} entity sets, total {1} types'.format(len(sets), len(all_types))) return base_class, sets, all_types - def load_document(self): + def load_document(self, raw_mode=False): self.log.info('Loading metadata document: {0}'.format(self.url)) response = self.connection._do_get(self.url) + if raw_mode: + return response.content.decode("utf-8") return ET.fromstring(response.content) def _parse_action(self, xmlq, action_element, schema_name): From cee99e08cb508b2d77fd2299282a6f5639643770 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Wed, 31 Mar 2021 10:19:24 +0200 Subject: [PATCH 10/19] Metadata schema from file --- odata/metadata.py | 13 ++++++++++--- odata/service.py | 4 +++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/odata/metadata.py b/odata/metadata.py index c15cf21..3ef6da9 100644 --- a/odata/metadata.py +++ b/odata/metadata.py @@ -251,10 +251,17 @@ def get_entity_or_prop_from_type(typename): def load_document(self, raw_mode=False): self.log.info('Loading metadata document: {0}'.format(self.url)) - response = self.connection._do_get(self.url) + + if self.service.schema_path is None: + response = self.connection._do_get(self.url) + response = response.content + else: + with open(self.service.schema_path) as fp: + response = fp.read() + if raw_mode: - return response.content.decode("utf-8") - return ET.fromstring(response.content) + return response.decode("utf-8") + return ET.fromstring(response) def _parse_action(self, xmlq, action_element, schema_name): action = { diff --git a/odata/service.py b/odata/service.py index 6238462..a4df550 100644 --- a/odata/service.py +++ b/odata/service.py @@ -76,12 +76,14 @@ class ODataService(object): :param auth: Custom Requests auth object to use for credentials :raises ODataConnectionError: Fetching metadata failed. Server returned an HTTP error code """ - def __init__(self, url, base=None, reflect_entities=False, session=None, auth=None): + def __init__(self, url, base=None, reflect_entities=False, session=None, auth=None, + schema_path = None): self.url = url self.metadata_url = '' self.collections = {} self.log = logging.getLogger('odata.service') self.default_context = Context(auth=auth, session=session) + self.schema_path = schema_path self.entities = {} """ From 8b6669bb93ace510ec5bb8e22b94c842f0216440 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Fri, 27 May 2022 14:16:22 +0200 Subject: [PATCH 11/19] extra header on save and raw query support --- odata/service.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/odata/service.py b/odata/service.py index a4df550..0e6ab3e 100644 --- a/odata/service.py +++ b/odata/service.py @@ -30,21 +30,21 @@ .. code-block:: python - >>> from requests.auth import HTTPBasicAuth - >>> my_auth = HTTPBasicAuth('username', 'password') - >>> Service = ODataService('url', auth=my_auth) + >> from requests.auth import HTTPBasicAuth + >> my_auth = HTTPBasicAuth('username', 'password') + >> Service = ODataService('url', auth=my_auth) NTLM Auth (for services like Microsoft Dynamics 2016): .. code-block:: python - >>> import requests - >>> from requests_ntlm import HttpNtlmAuth - >>> my_session = requests.Session() - >>> my_session.auth = HttpNtlmAuth('domain\\username', 'password') - >>> my_session.get('basic url') # should return 200 OK - >>> Service = ODataService('url', session=my_session) + >> import requests + >> from requests_ntlm import HttpNtlmAuth + >> my_session = requests.Session() + >> my_session.auth = HttpNtlmAuth('domain\\username', 'password') + >> my_session.get('basic url') # should return 200 OK + >> Service = ODataService('url', session=my_session) ---- @@ -82,7 +82,7 @@ def __init__(self, url, base=None, reflect_entities=False, session=None, auth=No self.metadata_url = '' self.collections = {} self.log = logging.getLogger('odata.service') - self.default_context = Context(auth=auth, session=session) + self.default_context = Context(auth=auth, session=session, base_url=url) self.schema_path = schema_path self.entities = {} @@ -149,7 +149,7 @@ def __init__(self, url, base=None, reflect_entities=False, session=None, auth=No def __repr__(self): return u''.format(self.url) - def create_context(self, auth=None, session=None): + def create_context(self, auth=None, session=None, base_url=None): """ Create new context to use for session-like usage @@ -158,7 +158,7 @@ def create_context(self, auth=None, session=None): :return: Context instance :rtype: Context """ - return Context(auth=auth, session=session) + return Context(auth=auth, session=session, base_url=base_url) def describe(self, entity): """ @@ -190,7 +190,7 @@ def delete(self, entity): """ return self.default_context.delete(entity) - def save(self, entity, force_refresh=True): + def save(self, entity, force_refresh=True, extra_headers=None): """ Creates a POST or PATCH call to the service. If the entity already has a primary key, an update is called. Otherwise the entity is inserted @@ -198,6 +198,7 @@ def save(self, entity, force_refresh=True): :param entity: Model instance to insert or update :param force_refresh: Read full entity data again from service after PATCH call + :param extra_headers: Add custom headers on patch, post (Example:B1S-ReplaceCollectionsOnPatch=true) :raises ODataConnectionError: Invalid data or serverside error. Server returned an HTTP error code """ - return self.default_context.save(entity, force_refresh=force_refresh) + return self.default_context.save(entity, force_refresh=force_refresh, extra_headers=extra_headers) From 27a5ce86b99973b1f92b89068546d7b095c6ddcb Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Fri, 27 May 2022 14:21:38 +0200 Subject: [PATCH 12/19] raw query fixes --- odata/query.py | 50 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/odata/query.py b/odata/query.py index 4d276ad..412dbdb 100644 --- a/odata/query.py +++ b/odata/query.py @@ -22,20 +22,20 @@ .. code-block:: python - >>> first_order = query.filter(...).filter(...).order_by(...).first() + >> first_order = query.filter(...).filter(...).order_by(...).first() The resulting objects can be fetched with :py:func:`~Query.first`, :py:func:`~Query.one`, :py:func:`~Query.all`, :py:func:`~Query.get` or just iterating the Query object itself. Network is not accessed until one of these ways is triggered. -Navigation properties can be loaded in the same request with +Navigation properties can be loaded in the same request with :py:func:`~Query.expand`: .. code-block:: python - >>> query.expand(Order.Shipper, Order.Customer) - >>> order = query.first() + >> query.expand(Order.Shipper, Order.Customer) + >> order = query.first() ---- @@ -58,10 +58,11 @@ class Query(object): This class should not be instantiated directly, but from a :py:class:`~odata.service.ODataService` object. """ - def __init__(self, entitycls, connection=None, options=None): + def __init__(self, entitycls, connection=None, options=None, base_url=None): self.entity = entitycls self.options = options or dict() self.connection = connection + self.base_url = base_url def __iter__(self): url = self._get_url() @@ -74,11 +75,14 @@ def __iter__(self): yield self._create_model(row) if '@odata.nextLink' in data: - url = urljoin(self.entity.__odata_url_base__, data['@odata.nextLink']) + if isinstance(self.entity, str): + url = urljoin(self.base_url, data['@odata.nextLink']) + else: + url = urljoin(self.entity.__odata_url_base__, data['@odata.nextLink']) options = {} # we get all options in the nextLink url else: break - elif self.entity.__odata_singleton__: + elif isinstance(self.entity, str) or self.entity.__odata_singleton__: yield self._create_model(data) break else: @@ -91,6 +95,9 @@ def __str__(self): return self.as_string() def _get_url(self): + if isinstance(self.entity, str): + return urljoin(self.base_url, self.entity) + return self.entity.__odata_url__() def _get_options(self): @@ -128,6 +135,8 @@ def _get_options(self): def _create_model(self, row): if len(self.options.get('$select', [])): return row + elif isinstance(self.entity, str): + return row else: e = self.entity.__new__(self.entity, from_data=row) es = e.__odata__ @@ -153,10 +162,10 @@ def _new_query(self): o['$top'] = self.options.get('$top', None) o['$skip'] = self.options.get('$skip', None) o['$select'] = self.options.get('$select', [])[:] - o['$filter'] = self.options.get('$filter', [])[:] o['$expand'] = self.options.get('$expand', [])[:] + o['$filter'] = self.options.get('$filter', [])[:] o['$orderby'] = self.options.get('$orderby', [])[:] - return Query(self.entity, options=o, connection=self.connection) + return Query(self.entity, options=o, connection=self.connection, base_url=self.base_url) def as_string(self): query = self._format_params(self._get_options()) @@ -199,7 +208,10 @@ def expand(self, *values): q = self._new_query() option = q._get_or_create_option('$expand') for prop in values: - option.append(prop.name) + if isinstance(prop, str): + option.append(prop) + else: + option.append(prop.name) return q def order_by(self, *values): @@ -301,6 +313,17 @@ def get(self, *pk, **composite_keys): :param composite_keys: Primary key values for Entities with composite keys :return: Entity instance or None """ + if isinstance(self.entity, str): + url = f"{self._get_url()}({pk[0]})" + extra_headers = { + "Prefer": "odata.maxpagesize=0" # Pagination turned off in raw mode + } + response_data = self.connection.execute_get(url, extra_headers=extra_headers) + if not response_data: + exc.NoResultsFound() + + return (response_data or {}).get('value') + i = self.entity.__new__(self.entity) es = i.__odata__ @@ -340,6 +363,9 @@ def raw(self, query_params): :type query_params: dict :return: Query result """ - url = self.entity.__odata_url__() - response_data = self.connection.execute_get(url, params=query_params) + url = self._get_url() + extra_headers = { + "Prefer": "odata.maxpagesize=0" # Pagination turned off in raw mode + } + response_data = self.connection.execute_get(url, params=query_params, extra_headers=extra_headers) return (response_data or {}).get('value') From b9defe626dd3361d19e61caa55624ef6bacdcfcd Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Fri, 27 May 2022 14:25:05 +0200 Subject: [PATCH 13/19] fix comments --- odata/action.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/odata/action.py b/odata/action.py index deea3d8..4d0dfda 100644 --- a/odata/action.py +++ b/odata/action.py @@ -11,17 +11,17 @@ .. code-block:: python - >>> from odata import ODataService - >>> Service = ODataService(url, reflect_entities=True) - >>> Product = Service.entities['Product'] + >> from odata import ODataService + >> Service = ODataService(url, reflect_entities=True) + >> Product = Service.entities['Product'] - >>> prod = Service.query(Product).get(1234) - >>> prod.GetAvailabilityDate() + >> prod = Service.query(Product).get(1234) + >> prod.GetAvailabilityDate() datetime.datetime(2018, 6, 1, 12, 0, 0) - >>> import datetime - >>> GetExampleDecimal = Service.functions['GetExampleDecimal'] - >>> GetExampleDecimal(Date=datetime.datetime.now()) + >> import datetime + >> GetExampleDecimal = Service.functions['GetExampleDecimal'] + >> GetExampleDecimal(Date=datetime.datetime.now()) Decimal('34.0') @@ -49,9 +49,9 @@ class _GetExampleDecimal(Service.Function): .. code-block:: python - >>> import datetime - >>> # calls GET http://service/GetExampleDecimal(Date=2017-01-01T12:00:00Z) - >>> GetExampleDecimal(Date=datetime.datetime.now()) + >>import datetime + >># calls GET http://service/GetExampleDecimal(Date=2017-01-01T12:00:00Z) + >>GetExampleDecimal(Date=datetime.datetime.now()) Decimal('34.0') @@ -92,19 +92,19 @@ class Product(Service.Entity): .. code-block:: python - >>> # collection bound Action. calls POST http://service/Product/ODataService.RemoveAllReservations - >>> Product.RemoveAllReservations() + >> # collection bound Action. calls POST http://service/Product/ODataService.RemoveAllReservations + >> Product.RemoveAllReservations() True - >>> # if the Action is instance bound, call the Action from the Product instance instead - >>> from decimal import Decimal - >>> prod = Service.query(Product).get(1234) - >>> # calls POST http://service/Product(1234)/ODataService.ReserveAmount - >>> prod.ReserveAmount(Amount=Decimal('5.0')) + >> # if the Action is instance bound, call the Action from the Product instance instead + >> from decimal import Decimal + >> prod = Service.query(Product).get(1234) + >> # calls POST http://service/Product(1234)/ODataService.ReserveAmount + >> prod.ReserveAmount(Amount=Decimal('5.0')) True - >>> # calls GET http://service/Product(1234)/ODataService.GetAvailabilityDate() - >>> prod.GetAvailabilityDate() + >> # calls GET http://service/Product(1234)/ODataService.GetAvailabilityDate() + >> prod.GetAvailabilityDate() datetime.datetime(2018, 6, 1, 12, 0, 0) From 3d742f0c2b509f2afbb0be1a2e17efb913c5ef6f Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Fri, 27 May 2022 14:26:03 +0200 Subject: [PATCH 14/19] extra header --- odata/connection.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/odata/connection.py b/odata/connection.py index deefd27..64a7940 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -26,7 +26,7 @@ class ODataConnection(object): base_headers = { 'Accept': 'application/json', 'OData-Version': '4.0', - 'User-Agent': 'python-odata {0}'.format(version), + 'User-Agent': 'python-odata {0}'.format(__version__), } timeout = 90 @@ -96,10 +96,13 @@ def _handle_odata_error(self, response): err.detailed_message = detailed_message raise err - def execute_get(self, url, params=None): + def execute_get(self, url, params=None, extra_headers=None): headers = {} headers.update(self.base_headers) + if extra_headers: + headers.update(extra_headers) + self.log.info(u'GET {0}'.format(url)) if params: self.log.info(u'Query: {0}'.format(params)) @@ -116,12 +119,15 @@ def execute_get(self, url, params=None): msg = u'Unsupported response Content-Type: {0}'.format(response_ct) raise ODataError(msg) - def execute_post(self, url, data, params=None): + def execute_post(self, url, data, params=None, extra_headers=None): headers = { 'Content-Type': 'application/json', } headers.update(self.base_headers) + if extra_headers: + headers.update(extra_headers) + data = json.dumps(data) self.log.info(u'POST {0}'.format(url)) @@ -136,12 +142,15 @@ def execute_post(self, url, data, params=None): return response.json() # no exceptions here, POSTing to Actions may not return data - def execute_patch(self, url, data): + def execute_patch(self, url, data, extra_headers=None): headers = { 'Content-Type': 'application/json', } headers.update(self.base_headers) + if extra_headers: + headers.update(extra_headers) + data = json.dumps(data) self.log.info(u'PATCH {0}'.format(url)) @@ -150,10 +159,13 @@ def execute_patch(self, url, data): response = self._do_patch(url, data=data, headers=headers) self._handle_odata_error(response) - def execute_delete(self, url): + def execute_delete(self, url, extra_headers=None): headers = {} headers.update(self.base_headers) + if extra_headers: + headers.update(extra_headers) + self.log.info(u'DELETE {0}'.format(url)) response = self._do_delete(url, headers=headers) From 8984c0aa7203b82508418acf77f1da8bb1885ab3 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Fri, 27 May 2022 14:27:15 +0200 Subject: [PATCH 15/19] extra header and raw query support --- odata/context.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/odata/context.py b/odata/context.py index d85e47d..f37b3de 100644 --- a/odata/context.py +++ b/odata/context.py @@ -9,13 +9,13 @@ class Context: - def __init__(self, session=None, auth=None): + def __init__(self, session=None, auth=None, base_url=None): self.log = logging.getLogger('odata.context') self.connection = ODataConnection(session=session, auth=auth) + self.base_url = base_url def query(self, entitycls): - q = Query(entitycls, connection=self.connection) - return q + return Query(entitycls, connection=self.connection, base_url=self.base_url) def call(self, action_or_function, **parameters): """ @@ -50,7 +50,7 @@ def delete(self, entity): entity.__odata__.persisted = False self.log.info(u'Success') - def save(self, entity, force_refresh=True): + def save(self, entity, force_refresh=True, extra_headers=None): """ Creates a POST or PATCH call to the service. If the entity already has a primary key, an update is called. Otherwise the entity is inserted @@ -59,11 +59,12 @@ def save(self, entity, force_refresh=True): :param entity: Model instance to insert or update :type entity: EntityBase :param force_refresh: Read full entity data again from service after PATCH call + :param extra_headers: Add custom headers on patch, post (Example:B1S-ReplaceCollectionsOnPatch=true) :raises ODataConnectionError: Invalid data or serverside error. Server returned an HTTP error code """ if self.is_entity_saved(entity): - self._update_existing(entity, force_refresh=force_refresh) + self._update_existing(entity, force_refresh=force_refresh, extra_headers=extra_headers) else: self._insert_new(entity) @@ -95,7 +96,7 @@ def _insert_new(self, entity): self.log.info(u'Success') - def _update_existing(self, entity, force_refresh=True): + def _update_existing(self, entity, force_refresh=True, extra_headers=None): """ Creates a PATCH call to the service, sending only the modified values @@ -116,7 +117,7 @@ def _update_existing(self, entity, force_refresh=True): url = es.instance_url - saved_data = self.connection.execute_patch(url, patch_data) + saved_data = self.connection.execute_patch(url, patch_data, extra_headers) es.reset() if saved_data is None and force_refresh: From 3915a09b351d15c2be14fe548c036b3186276f66 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Fri, 27 May 2022 14:29:55 +0200 Subject: [PATCH 16/19] fix comments --- odata/navproperty.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/odata/navproperty.py b/odata/navproperty.py index 4c5527c..fb5b052 100644 --- a/odata/navproperty.py +++ b/odata/navproperty.py @@ -9,10 +9,10 @@ .. code-block:: python - >>> order = Service.query(Order).first() - >>> order.Shipper + >> order = Service.query(Order).first() + >> order.Shipper - >>> order.Shipper.CompanyName + >> order.Shipper.CompanyName 'Federal Shipping' When creating new instances, relationships can be assigned via navigation From a6b7fe2f28b62e780f7093aff7d7d46514defb70 Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Fri, 27 May 2022 14:30:14 +0200 Subject: [PATCH 17/19] add contains filter --- odata/property.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/odata/property.py b/odata/property.py index adf395b..6e002d1 100644 --- a/odata/property.py +++ b/odata/property.py @@ -194,6 +194,19 @@ def endswith(self, value): value = self.escape_value(value) return u'endswith({0}, {1})'.format(self.name, value) + def contains(self, value): + """Extend the StringProperty with contains method""" + value = self.escape_value(value) + return u'contains({0}, {1})'.format(self.name, value) + + def not_contains(self, value): + """Does not contain""" + value = self.escape_value(value) + return u'not(contains({0}, {1}))'.format(self.name, value) + + def trim(self): + return u'trim({0})'.format(self.name) + class IntegerProperty(PropertyBase): """ From c97b50d42219e2c8faadde47ee83c99537fa7c7f Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Thu, 16 Jun 2022 08:36:34 +0200 Subject: [PATCH 18/19] fix actions --- odata/action.py | 2 +- odata/metadata.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/odata/action.py b/odata/action.py index 4d0dfda..62bd900 100644 --- a/odata/action.py +++ b/odata/action.py @@ -246,7 +246,7 @@ def _callable(self, connection, url, query, **kwargs): query_options = query._get_options() response_data = self._execute_http(connection, url, query_options, kwargs) - response_data = (response_data or {}).get('value') + response_data = (response_data or {}).get('value', response_data) simple_types_values = self.__odata_service__.metadata.property_types.values() diff --git a/odata/metadata.py b/odata/metadata.py index 3ef6da9..f90882f 100644 --- a/odata/metadata.py +++ b/odata/metadata.py @@ -281,7 +281,7 @@ def _parse_action(self, xmlq, action_element, schema_name): for i, parameter_element in enumerate(xmlq(action_element, 'edm:Parameter')): parameter_name = parameter_element.attrib['Name'] - if i == 0: + if i == 0 and action['is_bound']: action['is_bound_to'] = parameter_element.attrib['Type'] continue From a83be96d1953315eda4f19114793563a756a515e Mon Sep 17 00:00:00 2001 From: Ruben Sanchez Date: Tue, 28 Feb 2023 13:18:03 +0100 Subject: [PATCH 19/19] fix enum property writes --- odata/enumtype.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/enumtype.py b/odata/enumtype.py index 8c032fa..b5d4b2a 100644 --- a/odata/enumtype.py +++ b/odata/enumtype.py @@ -22,7 +22,7 @@ def __init__(self, name, enum_class=EnumType): self.enum_class = enum_class def serialize(self, value): - return value.name + return value.name if hasattr(value, "name") else value def deserialize(self, value): return self.enum_class[value]