From 4f063673710d8e480f8825c170b7aa0db631e0cf Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 15 Apr 2020 11:49:19 +0200 Subject: [PATCH 01/57] Adding the possiblity for custom options --- odata/context.py | 4 ++-- odata/query.py | 5 +++-- odata/service.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/odata/context.py b/odata/context.py index d85e47d..f00d3d4 100644 --- a/odata/context.py +++ b/odata/context.py @@ -13,8 +13,8 @@ def __init__(self, session=None, auth=None): self.log = logging.getLogger('odata.context') self.connection = ODataConnection(session=session, auth=auth) - def query(self, entitycls): - q = Query(entitycls, connection=self.connection) + def query(self, entitycls, options=None): + q = Query(entitycls, connection=self.connection, options=options) return q def call(self, action_or_function, **parameters): diff --git a/odata/query.py b/odata/query.py index 4d276ad..d0aa6ca 100644 --- a/odata/query.py +++ b/odata/query.py @@ -61,6 +61,7 @@ class Query(object): def __init__(self, entitycls, connection=None, options=None): self.entity = entitycls self.options = options or dict() + self.default_opts = options self.connection = connection def __iter__(self): @@ -98,7 +99,7 @@ def _get_options(self): Format current query options to a dict that can be passed to requests :return: Dictionary """ - options = dict() + options = self.options _top = self.options.get('$top') if _top is not None: @@ -149,7 +150,7 @@ def _new_query(self): :return: Query instance """ - o = dict() + o = self.default_opts or dict() o['$top'] = self.options.get('$top', None) o['$skip'] = self.options.get('$skip', None) o['$select'] = self.options.get('$select', [])[:] diff --git a/odata/service.py b/odata/service.py index 6238462..be34a08 100644 --- a/odata/service.py +++ b/odata/service.py @@ -170,14 +170,14 @@ def is_entity_saved(self, entity): """Returns boolean indicating entity's status""" return self.default_context.is_entity_saved(entity) - def query(self, entitycls): + def query(self, entitycls, options=None): """ Start a new query for given entity class :param entitycls: Entity to query :return: Query object """ - return self.default_context.query(entitycls) + return self.default_context.query(entitycls, options=options) def delete(self, entity): """ From bf0dd1b4a5de99e82f68658919d8472034a0f7c0 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 24 Jun 2020 14:54:13 +0200 Subject: [PATCH 02/57] Hacking in an OData scope --- odata/entity.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/odata/entity.py b/odata/entity.py index feab6bb..5c8936d 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -92,12 +92,33 @@ class EntityBase(object): __odata_type__ = 'ODataSchema.Entity' __odata_singleton__ = False __odata_schema__ = None + __odata_scope__ = None @classmethod def __odata_url__(cls): # used by Query if cls.__odata_collection__: - return urljoin(cls.__odata_service__.url, cls.__odata_collection__) + if cls.__odata_scope__: + if callable(cls.__odata_scope__): + return "/".join([ + urljoin( + cls.__odata_service__.url, + cls.__odata_scope__() + ), + cls.__odata_collection__ + + ]) + else: + return "/".join([ + urljoin( + cls.__odata_service__.url, + cls.__odata_scope__ + ), + cls.__odata_collection__ + + ]) + else: + return urljoin(cls.__odata_service__.url, cls.__odata_collection__) def __new__(cls, *args, **kwargs): i = super(EntityBase, cls).__new__(cls) From ac4092dd674d39c2a057a14fbf4392db054cb074 Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 30 Jun 2020 11:01:13 +0200 Subject: [PATCH 03/57] Made it work for me --- odata/entity.py | 6 +++++- odata/state.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/odata/entity.py b/odata/entity.py index 5c8936d..ef3a023 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -123,6 +123,7 @@ def __odata_url__(cls): def __new__(cls, *args, **kwargs): i = super(EntityBase, cls).__new__(cls) i.__odata__ = es = EntityState(i) + data = args[0] if 'from_data' in kwargs: raw_data = kwargs.pop('from_data') @@ -142,7 +143,10 @@ def __new__(cls, *args, **kwargs): i.__odata__.persisted = True else: for prop_name, prop in es.properties: - i.__odata__[prop.name] = None + if prop_name in data.keys(): + i.__odata__[prop.name] = data[prop_name] + else: + i.__odata__[prop.name] = None return i diff --git a/odata/state.py b/odata/state.py index c493c7e..91a7aee 100644 --- a/odata/state.py +++ b/odata/state.py @@ -210,4 +210,9 @@ def _clean_new_entity(self, entity): else: insert_data[prop.name] = self._clean_new_entity(value) + for _, prop in es.properties: + if prop.name in insert_data: + if not insert_data[prop.name]: + insert_data.pop(prop.name) + return insert_data From a61e54308066e559de5a4a2ffe5c10c15dce4364 Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 30 Jun 2020 16:53:25 +0200 Subject: [PATCH 04/57] Fixing the patch url --- odata/state.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index 91a7aee..4e4f6a5 100644 --- a/odata/state.py +++ b/odata/state.py @@ -3,6 +3,7 @@ from __future__ import print_function import os import inspect +import re from collections import OrderedDict from odata.property import PropertyBase, NavigationProperty @@ -96,7 +97,9 @@ def id(self): @property def instance_url(self): if self.id: - return self.entity.__odata_url_base__ + self.id + url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) + print(url + self.id) + return url + self.id @property def properties(self): From 56d0ca89573de672e6755d9561cfbf9ac9e594bc Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 1 Jul 2020 07:58:43 +0200 Subject: [PATCH 05/57] Added a GET --- odata/context.py | 11 +++++++++++ odata/service.py | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/odata/context.py b/odata/context.py index f00d3d4..edd4532 100644 --- a/odata/context.py +++ b/odata/context.py @@ -50,6 +50,17 @@ def delete(self, entity): entity.__odata__.persisted = False self.log.info(u'Success') + def get(self, entity): + """ + Creates a GET call to the service, fetching the entity + + :type entity: EntityBase + """ + self.log.info(u'Fetching entity: {0}'.format(entity)) + url = entity.__odata__.instance_url + self.connection.execute_get(url) + self.log.info(u'Success') + def save(self, entity, force_refresh=True): """ Creates a POST or PATCH call to the service. If the entity already has diff --git a/odata/service.py b/odata/service.py index be34a08..210a610 100644 --- a/odata/service.py +++ b/odata/service.py @@ -179,6 +179,14 @@ def query(self, entitycls, options=None): """ return self.default_context.query(entitycls, options=options) + def get(self, entity): + """ + Creates a GET call to the service, fetching the entity + + :type entity: EntityBase + """ + return self.default_context.get(entity) + def delete(self, entity): """ Creates a DELETE call to the service, deleting the entity From cd2217dcf6f821204bfe5efca8b79adbb59ad30e Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 1 Jul 2020 08:05:39 +0200 Subject: [PATCH 06/57] returning the shit --- odata/context.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/odata/context.py b/odata/context.py index edd4532..35e2382 100644 --- a/odata/context.py +++ b/odata/context.py @@ -58,8 +58,12 @@ def get(self, entity): """ self.log.info(u'Fetching entity: {0}'.format(entity)) url = entity.__odata__.instance_url - self.connection.execute_get(url) + data = self.connection.execute_get(url) + entity.__odata__.reset() + if data is not None: + entity.__odata__.update(data) self.log.info(u'Success') + return entity def save(self, entity, force_refresh=True): """ From 70b99c2390cfb33e227197787f2ef96ca9478580 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 1 Jul 2020 09:08:49 +0200 Subject: [PATCH 07/57] Removed clutter --- odata/state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index 4e4f6a5..4b54246 100644 --- a/odata/state.py +++ b/odata/state.py @@ -98,7 +98,6 @@ def id(self): def instance_url(self): if self.id: url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) - print(url + self.id) return url + self.id @property From f5273097080862c85e7e3ae1518cfc532ad55671 Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 2 Jul 2020 08:26:27 +0200 Subject: [PATCH 08/57] Fixing a case --- odata/entity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/odata/entity.py b/odata/entity.py index ef3a023..f3cf464 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -123,7 +123,10 @@ def __odata_url__(cls): def __new__(cls, *args, **kwargs): i = super(EntityBase, cls).__new__(cls) i.__odata__ = es = EntityState(i) - data = args[0] + if len(args) > 0: + data = args[0] + else: + data = {} if 'from_data' in kwargs: raw_data = kwargs.pop('from_data') From ac10d5c7490f4636a174c03bb5659fb84e4c3829 Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 14 Jul 2020 07:22:03 +0200 Subject: [PATCH 09/57] Make it persisted if it has an id already --- odata/state.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/odata/state.py b/odata/state.py index 4b54246..b834d2f 100644 --- a/odata/state.py +++ b/odata/state.py @@ -36,6 +36,8 @@ def get(self, key, default): def update(self, other): self.data.update(other) + if self.id is not None: + self.persisted = True # /dictionary access def __repr__(self): From 870cdfd3f23dddb9fd3cf4ab4d5bb0b789069fc4 Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 14 Jul 2020 15:43:45 +0200 Subject: [PATCH 10/57] Proper persisting --- odata/context.py | 1 + odata/state.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/odata/context.py b/odata/context.py index 35e2382..cb806c2 100644 --- a/odata/context.py +++ b/odata/context.py @@ -62,6 +62,7 @@ def get(self, entity): entity.__odata__.reset() if data is not None: entity.__odata__.update(data) + entity.__odata__.persisted = True self.log.info(u'Success') return entity diff --git a/odata/state.py b/odata/state.py index b834d2f..4b54246 100644 --- a/odata/state.py +++ b/odata/state.py @@ -36,8 +36,6 @@ def get(self, key, default): def update(self, other): self.data.update(other) - if self.id is not None: - self.persisted = True # /dictionary access def __repr__(self): From 29caf3eeaec96372925d65efe2458c6e91b8ffa4 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 20 Jul 2020 11:51:30 +0200 Subject: [PATCH 11/57] Caching metadata --- odata/metadata.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/odata/metadata.py b/odata/metadata.py index 5b76be8..cbf1944 100644 --- a/odata/metadata.py +++ b/odata/metadata.py @@ -20,6 +20,8 @@ class MetaData(object): + cached_entity_sets = {} + cached_metadata = {} log = logging.getLogger('odata.metadata') namespaces = { 'edm': 'http://docs.oasis-open.org/odata/ns/edm', @@ -210,7 +212,12 @@ def _create_functions(self, all_types, functions, get_entity_or_prop_from_type): self.service.functions[function['name']] = function_class() def get_entity_sets(self, base=None): - document = self.load_document() + if base not in MetaData.cached_entity_sets: + MetaData.cached_entity_sets[base] = self._get_entity_sets(base) + return MetaData.cached_entity_sets[base] + + def _get_entity_sets(self, base=None): + document = self.load_document(self.url) schemas, entity_sets, actions, functions = self.parse_document(document) base_class = base or declarative_base() @@ -249,10 +256,12 @@ 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): - self.log.info('Loading metadata document: {0}'.format(self.url)) - response = self.connection._do_get(self.url) - return ET.fromstring(response.content) + def load_document(self, url): + if url not in MetaData.cached_metadata: + self.log.info('Loading metadata document: {0}'.format(url)) + response = self.connection._do_get(url) + MetaData.cached_metadata[url] = ET.fromstring(response.content) + return MetaData.cached_metadata[url] def _parse_action(self, xmlq, action_element, schema_name): action = { From 787cd87681d58a78ed42d00a98afaf296da3c315 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 24 Aug 2020 09:32:32 +0200 Subject: [PATCH 12/57] s/info/debug --- odata/context.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/odata/context.py b/odata/context.py index cb806c2..d1e3a63 100644 --- a/odata/context.py +++ b/odata/context.py @@ -44,11 +44,11 @@ def delete(self, entity): :type entity: EntityBase :raises ODataConnectionError: Delete not allowed or a serverside error. Server returned an HTTP error code """ - self.log.info(u'Deleting entity: {0}'.format(entity)) + self.log.debug(u'Deleting entity: {0}'.format(entity)) url = entity.__odata__.instance_url self.connection.execute_delete(url) entity.__odata__.persisted = False - self.log.info(u'Success') + self.log.debug(u'Success') def get(self, entity): """ @@ -56,14 +56,14 @@ def get(self, entity): :type entity: EntityBase """ - self.log.info(u'Fetching entity: {0}'.format(entity)) + self.log.debug(u'Fetching entity: {0}'.format(entity)) url = entity.__odata__.instance_url data = self.connection.execute_get(url) entity.__odata__.reset() if data is not None: entity.__odata__.update(data) entity.__odata__.persisted = True - self.log.info(u'Success') + self.log.debug(u'Success') return entity def save(self, entity, force_refresh=True): @@ -97,7 +97,7 @@ def _insert_new(self, entity): msg = 'Cannot insert Entity that does not belong to EntitySet: {0}'.format(entity) raise ODataError(msg) - self.log.info(u'Saving new entity') + self.log.debug(u'Saving new entity') es = entity.__odata__ insert_data = es.data_for_insert() @@ -109,7 +109,7 @@ def _insert_new(self, entity): if saved_data is not None: es.update(saved_data) - self.log.info(u'Success') + self.log.debug(u'Success') def _update_existing(self, entity, force_refresh=True): """ @@ -128,7 +128,7 @@ def _update_existing(self, entity, force_refresh=True): self.log.debug(u'Nothing to update: {0}'.format(entity)) return - self.log.info(u'Updating existing entity: {0}'.format(entity)) + self.log.debug(u'Updating existing entity: {0}'.format(entity)) url = es.instance_url @@ -136,10 +136,10 @@ def _update_existing(self, entity, force_refresh=True): es.reset() if saved_data is None and force_refresh: - self.log.info(u'Reloading entity from service') + self.log.debug(u'Reloading entity from service') saved_data = self.connection.execute_get(url) if saved_data is not None: entity.__odata__.update(saved_data) - self.log.info(u'Success') + self.log.debug(u'Success') From df46d98284cd257e28fb174df1cec720e9837062 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 24 Aug 2020 11:23:00 +0200 Subject: [PATCH 13/57] Not None check --- odata/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index 4b54246..d58aaf6 100644 --- a/odata/state.py +++ b/odata/state.py @@ -82,7 +82,7 @@ def id(self): for prop_name, prop in self.primary_key_properties: value = self.data.get(prop.name) - if value: + if value is not None: ids.append((prop, str(prop.escape_value(value)))) if len(ids) == 1: key_value = ids[0][1] From e775774f95fa1ada30f4102a33525626a317f21a Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 23 Nov 2020 15:24:12 +0100 Subject: [PATCH 14/57] Flush metadata cache --- odata/metadata.py | 5 +++++ odata/service.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/odata/metadata.py b/odata/metadata.py index cbf1944..ec22ee0 100644 --- a/odata/metadata.py +++ b/odata/metadata.py @@ -47,6 +47,11 @@ def __init__(self, service): self.connection = service.default_context.connection self.service = service + @classmethod + def flush_cache(cls): + cls.cached_entity_sets = {} + cls.cached_metadata = {} + def property_type_to_python(self, edm_type): return self.property_types.get(edm_type, StringProperty) diff --git a/odata/service.py b/odata/service.py index 210a610..78eb0a7 100644 --- a/odata/service.py +++ b/odata/service.py @@ -158,6 +158,9 @@ def create_context(self, auth=None, session=None): """ return Context(auth=auth, session=session) + def flush(self): + MetaData.flush_cache() + def describe(self, entity): """ Print a debug screen of an entity instance From ff71e105db9f9e777c53683f4d2707656c8803cf Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 23 Nov 2020 15:42:57 +0100 Subject: [PATCH 15/57] Flush is a class method --- odata/service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/odata/service.py b/odata/service.py index 78eb0a7..a07b9b9 100644 --- a/odata/service.py +++ b/odata/service.py @@ -158,7 +158,8 @@ def create_context(self, auth=None, session=None): """ return Context(auth=auth, session=session) - def flush(self): + @classmethod + def flush_cache(cls): MetaData.flush_cache() def describe(self, entity): From 5aff6862d24907c2bdb20d3c25db6abac726fcef Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 11 Jan 2021 13:29:07 +0100 Subject: [PATCH 16/57] Added persisted_id --- odata/context.py | 2 ++ odata/entity.py | 1 + odata/state.py | 3 +++ 3 files changed, 6 insertions(+) diff --git a/odata/context.py b/odata/context.py index d1e3a63..c9e2fb4 100644 --- a/odata/context.py +++ b/odata/context.py @@ -63,6 +63,7 @@ def get(self, entity): if data is not None: entity.__odata__.update(data) entity.__odata__.persisted = True + entity.__odata__.persisted_id = entity.__odata__.id self.log.debug(u'Success') return entity @@ -108,6 +109,7 @@ def _insert_new(self, entity): if saved_data is not None: es.update(saved_data) + es.persisted_id = es.id self.log.debug(u'Success') diff --git a/odata/entity.py b/odata/entity.py index f3cf464..a97e441 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -144,6 +144,7 @@ def __new__(cls, *args, **kwargs): i.__odata__[prop.name] = raw_data.get(prop.name) i.__odata__.persisted = True + i.__odata__.persisted_id = i.__odata__.id else: for prop_name, prop in es.properties: if prop_name in data.keys(): diff --git a/odata/state.py b/odata/state.py index d58aaf6..42df528 100644 --- a/odata/state.py +++ b/odata/state.py @@ -20,6 +20,7 @@ def __init__(self, entity): self.connection = None # does this object exist serverside self.persisted = False + self.persisted_id = None # dictionary access def __getitem__(self, item): @@ -75,6 +76,8 @@ def reset(self): @property def id(self): + if self.persisted_id: + return self.persisted_id ids = [] entity_name = self.entity.__odata_collection__ if entity_name is None: From 88dc3de54e275ff1a17ee96c1b696f9586a0c649 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 11 Jan 2021 13:33:59 +0100 Subject: [PATCH 17/57] Also resetting persisted_id --- odata/context.py | 1 + odata/state.py | 1 + 2 files changed, 2 insertions(+) diff --git a/odata/context.py b/odata/context.py index c9e2fb4..09fe8ef 100644 --- a/odata/context.py +++ b/odata/context.py @@ -48,6 +48,7 @@ def delete(self, entity): url = entity.__odata__.instance_url self.connection.execute_delete(url) entity.__odata__.persisted = False + entity.__odata__.persisted_id = None self.log.debug(u'Success') def get(self, entity): diff --git a/odata/state.py b/odata/state.py index 42df528..7bbedff 100644 --- a/odata/state.py +++ b/odata/state.py @@ -73,6 +73,7 @@ def describe(self): def reset(self): self.dirty = [] self.nav_cache = {} + self.persisted_id = None @property def id(self): From 26daf04f54c519be0f18f6cc270a05a91f30c729 Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 16 Feb 2021 15:37:17 +0100 Subject: [PATCH 18/57] Fixing a memory leak --- odata/connection.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/odata/connection.py b/odata/connection.py index 995557c..fc5f8b8 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -38,6 +38,9 @@ def __init__(self, session=None, auth=None): self.auth = auth self.log = logging.getLogger('odata.connection') + def __del__(self): + self.session.close() + def _apply_options(self, kwargs): kwargs['timeout'] = self.timeout @@ -108,12 +111,15 @@ def execute_get(self, url, params=None): self._handle_odata_error(response) response_ct = response.headers.get('content-type', '') if response.status_code == requests.codes.no_content: + response.close() return if 'application/json' in response_ct: data = response.json() + response.close() return data else: msg = u'Unsupported response Content-Type: {0}'.format(response_ct) + response.close() raise ODataError(msg) def execute_post(self, url, data, params=None): @@ -131,9 +137,12 @@ def execute_post(self, url, data, params=None): self._handle_odata_error(response) response_ct = response.headers.get('content-type', '') if response.status_code == requests.codes.no_content: + response.close() return if 'application/json' in response_ct: - return response.json() + resp_data = response.json() + response.close() + return resp_data # no exceptions here, POSTing to Actions may not return data def execute_patch(self, url, data): @@ -149,6 +158,7 @@ def execute_patch(self, url, data): response = self._do_patch(url, data=data, headers=headers) self._handle_odata_error(response) + response.close() def execute_delete(self, url): headers = {} @@ -158,3 +168,4 @@ def execute_delete(self, url): response = self._do_delete(url, headers=headers) self._handle_odata_error(response) + response.close() From ad92cbdeaa8f271d49eb735ffc02ce306dae25db Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 22 Feb 2021 08:37:08 +0100 Subject: [PATCH 19/57] Closing response on failure --- odata/connection.py | 136 +++++++++++++++++++++++++------------------- 1 file changed, 78 insertions(+), 58 deletions(-) diff --git a/odata/connection.py b/odata/connection.py index fc5f8b8..da5ee51 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -40,6 +40,7 @@ def __init__(self, session=None, auth=None): def __del__(self): self.session.close() + self.log.info(u'Closed session') def _apply_options(self, kwargs): kwargs['timeout'] = self.timeout @@ -100,72 +101,91 @@ def _handle_odata_error(self, response): raise err def execute_get(self, url, params=None): - headers = {} - headers.update(self.base_headers) - - self.log.info(u'GET {0}'.format(url)) - if params: - self.log.info(u'Query: {0}'.format(params)) - - response = self._do_get(url, params=params, headers=headers) - self._handle_odata_error(response) - response_ct = response.headers.get('content-type', '') - if response.status_code == requests.codes.no_content: - response.close() - return - if 'application/json' in response_ct: - data = response.json() - response.close() - return data - else: - msg = u'Unsupported response Content-Type: {0}'.format(response_ct) - response.close() - raise ODataError(msg) + try: + response = None + headers = {} + headers.update(self.base_headers) + + self.log.info(u'GET {0}'.format(url)) + if params: + self.log.info(u'Query: {0}'.format(params)) + + response = self._do_get(url, params=params, headers=headers) + self._handle_odata_error(response) + response_ct = response.headers.get('content-type', '') + if response.status_code == requests.codes.no_content: + return + if 'application/json' in response_ct: + return response.json() + else: + msg = u'Unsupported response Content-Type: {0}'.format(response_ct) + raise ODataError(msg) + except: + if response: + response.close() + self.log.info(u'Closed response after failed request') + raise def execute_post(self, url, data, params=None): - headers = { - 'Content-Type': 'application/json', - } - headers.update(self.base_headers) - - data = json.dumps(data) - - self.log.info(u'POST {0}'.format(url)) - self.log.info(u'Payload: {0}'.format(data)) - - response = self._do_post(url, data=data, headers=headers, params=params) - self._handle_odata_error(response) - response_ct = response.headers.get('content-type', '') - if response.status_code == requests.codes.no_content: - response.close() - return - if 'application/json' in response_ct: - resp_data = response.json() - response.close() - return resp_data - # no exceptions here, POSTing to Actions may not return data + try: + response = None + headers = { + 'Content-Type': 'application/json', + } + headers.update(self.base_headers) + + data = json.dumps(data) + + self.log.info(u'POST {0}'.format(url)) + self.log.info(u'Payload: {0}'.format(data)) + + response = self._do_post(url, data=data, headers=headers, params=params) + self._handle_odata_error(response) + response_ct = response.headers.get('content-type', '') + if response.status_code == requests.codes.no_content: + return + if 'application/json' in response_ct: + return response.json() + # no exceptions here, POSTing to Actions may not return data + except: + if response: + response.close() + self.log.info(u'Closed response after failed request') + raise def execute_patch(self, url, data): - headers = { - 'Content-Type': 'application/json', - } - headers.update(self.base_headers) + try: + response = None + headers = { + 'Content-Type': 'application/json', + } + headers.update(self.base_headers) - data = json.dumps(data) + data = json.dumps(data) - self.log.info(u'PATCH {0}'.format(url)) - self.log.info(u'Payload: {0}'.format(data)) + self.log.info(u'PATCH {0}'.format(url)) + self.log.info(u'Payload: {0}'.format(data)) - response = self._do_patch(url, data=data, headers=headers) - self._handle_odata_error(response) - response.close() + response = self._do_patch(url, data=data, headers=headers) + self._handle_odata_error(response) + except: + if response: + response.close() + self.log.info(u'Closed response after failed request') + raise def execute_delete(self, url): - headers = {} - headers.update(self.base_headers) + try: + response = None + headers = {} + headers.update(self.base_headers) - self.log.info(u'DELETE {0}'.format(url)) + self.log.info(u'DELETE {0}'.format(url)) - response = self._do_delete(url, headers=headers) - self._handle_odata_error(response) - response.close() + response = self._do_delete(url, headers=headers) + self._handle_odata_error(response) + except: + if response: + response.close() + self.log.info(u'Closed response after failed request') + raise From 306a1c0a9a56a9db0997304efaa54be93561d626 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 22 Feb 2021 10:27:01 +0100 Subject: [PATCH 20/57] even better response closing --- odata/connection.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/odata/connection.py b/odata/connection.py index da5ee51..36eb17e 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -121,10 +121,11 @@ def execute_get(self, url, params=None): msg = u'Unsupported response Content-Type: {0}'.format(response_ct) raise ODataError(msg) except: + raise + finally: if response: response.close() - self.log.info(u'Closed response after failed request') - raise + self.log.info(u'Closed GET response for {0}'.format(url)) def execute_post(self, url, data, params=None): try: @@ -148,10 +149,11 @@ def execute_post(self, url, data, params=None): return response.json() # no exceptions here, POSTing to Actions may not return data except: + raise + finally: if response: response.close() - self.log.info(u'Closed response after failed request') - raise + self.log.info(u'Closed POST response for {0}'.format(url)) def execute_patch(self, url, data): try: @@ -169,10 +171,11 @@ def execute_patch(self, url, data): response = self._do_patch(url, data=data, headers=headers) self._handle_odata_error(response) except: + raise + finally: if response: response.close() - self.log.info(u'Closed response after failed request') - raise + self.log.info(u'Closed PATCH response for {0}'.format(url)) def execute_delete(self, url): try: @@ -185,7 +188,8 @@ def execute_delete(self, url): response = self._do_delete(url, headers=headers) self._handle_odata_error(response) except: + raise + finally: if response: response.close() - self.log.info(u'Closed response after failed request') - raise + self.log.info(u'Closed DELETE response for {0}'.format(url)) From c8f308baae8a3da3d998d416d6c442a71edd0580 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 22 Feb 2021 10:50:10 +0100 Subject: [PATCH 21/57] Even more response closing forced --- odata/connection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/odata/connection.py b/odata/connection.py index 36eb17e..1e361d8 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -92,6 +92,9 @@ def _handle_odata_error(self, response): ie = odata_error['innererror'] detailed_message = ie.get('message') or detailed_message + response.close() + self.log.info(u'Closed response on failure with HTTP status {0}'.format(code)) + msg = ' | '.join([status_code, code, message, detailed_message]) err = ODataError(msg) err.status_code = status_code From 3a4553b0500145fe8a8049f719de7af1beec9623 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 7 Apr 2021 07:43:17 +0200 Subject: [PATCH 22/57] Made it an OrderedDict --- odata/connection.py | 1 + odata/context.py | 1 + odata/state.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/odata/connection.py b/odata/connection.py index 1e361d8..2d56f08 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -131,6 +131,7 @@ def execute_get(self, url, params=None): self.log.info(u'Closed GET response for {0}'.format(url)) def execute_post(self, url, data, params=None): + self.log.info(u'DATA: {0}'.format(data)) try: response = None headers = { diff --git a/odata/context.py b/odata/context.py index 09fe8ef..03b404b 100644 --- a/odata/context.py +++ b/odata/context.py @@ -103,6 +103,7 @@ def _insert_new(self, entity): es = entity.__odata__ insert_data = es.data_for_insert() + self.log.info(u'DATA FOR INSERT: {0}'.format(data)) saved_data = self.connection.execute_post(url, insert_data) es.reset() es.connection = self.connection diff --git a/odata/state.py b/odata/state.py index 7bbedff..d4e80b0 100644 --- a/odata/state.py +++ b/odata/state.py @@ -16,7 +16,7 @@ def __init__(self, entity): self.entity = entity self.dirty = [] self.nav_cache = {} - self.data = {} + self.data = OrderedDict() self.connection = None # does this object exist serverside self.persisted = False From 38ee44684e5afc0f49a4bf62548921aba71dafca Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 7 Apr 2021 07:45:01 +0200 Subject: [PATCH 23/57] Don't pop the FK values --- odata/connection.py | 1 - odata/context.py | 1 - odata/state.py | 8 +++++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/odata/connection.py b/odata/connection.py index 2d56f08..1e361d8 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -131,7 +131,6 @@ def execute_get(self, url, params=None): self.log.info(u'Closed GET response for {0}'.format(url)) def execute_post(self, url, data, params=None): - self.log.info(u'DATA: {0}'.format(data)) try: response = None headers = { diff --git a/odata/context.py b/odata/context.py index 03b404b..09fe8ef 100644 --- a/odata/context.py +++ b/odata/context.py @@ -103,7 +103,6 @@ def _insert_new(self, entity): es = entity.__odata__ insert_data = es.data_for_insert() - self.log.info(u'DATA FOR INSERT: {0}'.format(data)) saved_data = self.connection.execute_post(url, insert_data) es.reset() es.connection = self.connection diff --git a/odata/state.py b/odata/state.py index d4e80b0..0295972 100644 --- a/odata/state.py +++ b/odata/state.py @@ -3,15 +3,18 @@ from __future__ import print_function import os import inspect +import logging import re from collections import OrderedDict from odata.property import PropertyBase, NavigationProperty +import odata class EntityState(object): def __init__(self, entity): + self.log = logging.getLogger('odata.state') """:type entity: EntityBase """ self.entity = entity self.dirty = [] @@ -173,6 +176,7 @@ def _clean_new_entity(self, entity): insert_data['@odata.type'] = entity.__odata_type__ es = entity.__odata__ + for _, prop in es.properties: if prop.is_computed_value: continue @@ -186,8 +190,6 @@ def _clean_new_entity(self, entity): # 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]""" @@ -213,7 +215,7 @@ def _clean_new_entity(self, entity): else: if value.__odata__.id: insert_data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id - else: + elif isinstance(value, odata.entity.EntityBase): insert_data[prop.name] = self._clean_new_entity(value) for _, prop in es.properties: From 112ce7fef354eb825b8ad28b0700db0f3d97b9c0 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 7 Apr 2021 10:32:37 +0200 Subject: [PATCH 24/57] Don't need to log this --- odata/connection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/odata/connection.py b/odata/connection.py index 1e361d8..c135bc2 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -40,7 +40,6 @@ def __init__(self, session=None, auth=None): def __del__(self): self.session.close() - self.log.info(u'Closed session') def _apply_options(self, kwargs): kwargs['timeout'] = self.timeout From 00aedce0a22d27c952109871dd7be2bf2954fb29 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 3 May 2021 13:30:34 +0200 Subject: [PATCH 25/57] Bugfix --- odata/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index 0295972..b00b68f 100644 --- a/odata/state.py +++ b/odata/state.py @@ -36,7 +36,7 @@ def __contains__(self, item): return item in self.data def get(self, key, default): - return self.data.get(key, default=default) + return self.data.get(key, default) def update(self, other): self.data.update(other) From 4018df078ca454cd34781f8876f6df244359fedf Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 3 May 2021 15:51:57 +0200 Subject: [PATCH 26/57] State improved --- odata/state.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/odata/state.py b/odata/state.py index b00b68f..f4f2d80 100644 --- a/odata/state.py +++ b/odata/state.py @@ -164,10 +164,18 @@ def data_for_update(self): """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" if value is not None: key = '{0}@odata.bind'.format(prop.name) - if prop.is_collection: - update_data[key] = [i.__odata__.id for i in value] + if prop.is_collection and value: + ids = [i.__odata__.id for i in value if i.__odata__.id is not None] + if ids: + update_data[key] = ids + objs = [self._clean_new_entity(i) for i in value if i.__odata__.id is None] + if objs: + update_data[prop.name] = objs else: - update_data[key] = value.__odata__.id + if value.__odata__.id: + update_data[key] = value.__odata__.id + else: + update_data[prop.name] = value return update_data def _clean_new_entity(self, entity): From aa0a00c96e6925abe8fd417ee0408b8dba097e07 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 5 May 2021 09:48:11 +0200 Subject: [PATCH 27/57] Added .github unittest action --- .github/workflows/unittest.yaml | 30 ++++++++++++++++++++++++++++++ requirements.txt | Bin 0 -> 150 bytes 2 files changed, 30 insertions(+) create mode 100644 .github/workflows/unittest.yaml create mode 100644 requirements.txt diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml new file mode 100644 index 0000000..05d0497 --- /dev/null +++ b/.github/workflows/unittest.yaml @@ -0,0 +1,30 @@ +name: Python Unit Test + +on: [push] + +jobs: + build: + name: GitHub Action for Pytest + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Test with unittest + run: | + pytest ./odata/tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..af4cb2067e7150e80dc1f8983269cf416c65da84 GIT binary patch literal 150 zcmX|)-3mZJ5Ju0n@+ibAx$!WHqU3Mwg_p<7tTfHn)SS-u+#44s175O~smUp1OHvZz rcVuNDqo*@ANKVYgJ7JrjKg6^XKA-;yM`d1hD&xhhPH(sN#?DB+35FPo literal 0 HcmV?d00001 From 8765f4802b4c05e7964da0edce16f71a21bcef8d Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 5 May 2021 09:48:59 +0200 Subject: [PATCH 28/57] Improved the State --- odata/state.py | 131 +++++++++++++++++++++-------------- odata/tests/test_metadata.py | 3 + odata/tests/test_state.py | 38 ++++++++++ 3 files changed, 119 insertions(+), 53 deletions(-) create mode 100644 odata/tests/test_state.py diff --git a/odata/state.py b/odata/state.py index f4f2d80..dac0181 100644 --- a/odata/state.py +++ b/odata/state.py @@ -146,39 +146,12 @@ def set_property_dirty(self, prop): self.dirty.append(prop.name) def data_for_insert(self): - return self._clean_new_entity(self.entity) + return self._new_entity(self.entity) def data_for_update(self): - update_data = OrderedDict() - update_data['@odata.type'] = self.entity.__odata_type__ - - for _, prop in self.dirty_properties: - if prop.is_computed_value: - continue - - update_data[prop.name] = self.data[prop.name] + return self._updated_entity(self.entity) - for prop_name, prop in self.navigation_properties: - if prop.name in self.dirty: - value = getattr(self.entity, prop_name, None) # get the related object - """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" - if value is not None: - key = '{0}@odata.bind'.format(prop.name) - if prop.is_collection and value: - ids = [i.__odata__.id for i in value if i.__odata__.id is not None] - if ids: - update_data[key] = ids - objs = [self._clean_new_entity(i) for i in value if i.__odata__.id is None] - if objs: - update_data[prop.name] = objs - else: - if value.__odata__.id: - update_data[key] = value.__odata__.id - else: - update_data[prop.name] = value - return update_data - - def _clean_new_entity(self, entity): + def _new_entity(self, entity): """:type entity: odata.entity.EntityBase """ insert_data = OrderedDict() insert_data['@odata.type'] = entity.__odata_type__ @@ -201,34 +174,86 @@ def _clean_new_entity(self, entity): value = getattr(entity, prop_name, None) """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" - if value is not None: + insert_data = self._add_or_update_associated(insert_data, prop, value) - if prop.is_collection: - binds = [] + for _, prop in es.properties: + if prop.name in insert_data: + if not insert_data[prop.name]: + insert_data.pop(prop.name) - # binds must be added first - for i in [i for i in value if i.__odata__.id]: - binds.append(i.__odata__.id) + return insert_data - if len(binds): - insert_data['{0}@odata.bind'.format(prop.name)] = binds + def _updated_entity(self, entity): + update_data = OrderedDict() + update_data['@odata.type'] = self.entity.__odata_type__ - new_entities = [] - for i in [i for i in value if i.__odata__.id is None]: - new_entities.append(self._clean_new_entity(i)) + es = entity.__odata__ - if len(new_entities): - insert_data[prop.name] = new_entities + for _, prop in es.dirty_properties: + if prop.is_computed_value: + continue - else: - if value.__odata__.id: - insert_data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id - elif isinstance(value, odata.entity.EntityBase): - insert_data[prop.name] = self._clean_new_entity(value) + update_data[prop.name] = es[prop.name] - for _, prop in es.properties: - if prop.name in insert_data: - if not insert_data[prop.name]: - insert_data.pop(prop.name) + for prop_name, prop in es.navigation_properties: + if prop.name in es.dirty: + value = getattr(entity, prop_name, None) # get the related object + """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" + update_data = self._add_or_update_associated(update_data, prop, value) - return insert_data + return update_data + + def _add_or_update_associated(self, data, prop, value): + if value is None: + return data + if prop.is_collection: + data = self._add_or_update_associated_collection(data, prop, value) + else: + data = self._add_or_update_associated_instance(data, prop, value) + return data + + def _add_or_update_associated_collection(self, data, prop, value): + + def is_new(entity): + if entity.__odata__.id is None: + return True + return False + + def is_dirty(entity): + if is_new(entity): + return False + elif hasattr(entity.__odata__, 'dirty') and entity.__odata__.dirty: + return True + return False + + def is_persisted(entity): + return (not is_new(entity) and not is_dirty(entity)) + + ids = [i.__odata__.id for i in value if is_persisted(i)] + if ids: + data['{0}@odata.bind'.format(prop.name)] = ids + + upd_objs = [self._updated_entity(i) for i in value if is_dirty(i)] + + new_objs = [self._new_entity(i) for i in value if is_new(i)] + + if upd_objs or new_objs: + data[prop.name] = upd_objs + new_objs + + return data + + def _add_or_update_associated_instance(self, data, prop, value): + if isinstance(value, odata.entity.EntityBase): + if value.persisted is False: + data[prop.name] = self._new_entity(value) + + elif value.dirty: + data[prop.name] = self._updated_entity(value) + + elif value.__odata__.id: + data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id + + elif value.__odata__.id: + data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id + + return data diff --git a/odata/tests/test_metadata.py b/odata/tests/test_metadata.py index 8d6029b..a5e39ba 100644 --- a/odata/tests/test_metadata.py +++ b/odata/tests/test_metadata.py @@ -9,6 +9,7 @@ from odata import ODataService from odata.entity import EntityBase +from odata.metadata import MetaData path = os.path.join(os.path.dirname(__file__), 'demo_metadata.xml') with open(path, mode='rb') as f: @@ -18,6 +19,7 @@ class TestMetadataImport(TestCase): def test_read(self): + MetaData.flush_cache() with responses.RequestsMock() as rsps: rsps.add(rsps.GET, 'http://demo.local/odata/$metadata/', body=metadata_xml, content_type='text/xml') @@ -46,6 +48,7 @@ def test_read(self): self.assertIn('DemoUnboundAction', Service.actions) def test_computed_value_in_insert(self): + MetaData.flush_cache() with responses.RequestsMock() as rsps: rsps.add(rsps.GET, 'http://demo.local/odata/$metadata/', body=metadata_xml, content_type='text/xml') diff --git a/odata/tests/test_state.py b/odata/tests/test_state.py new file mode 100644 index 0000000..a173259 --- /dev/null +++ b/odata/tests/test_state.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +import unittest + +from odata.state import EntityState +from odata.tests import Product, ProductPart + + +class TestSate(unittest.TestCase): + + def test_new_entity(self): + uuid = '3d46cd74-a3af-4afd-af94-512b5cee1ef0' + + product = Product() + product.id = uuid + product.name = u'Defender' + product.category = u'Cars' + product.price = 40000.00 + + state = EntityState(product) + + data = dict(state.data_for_insert()) + + assert data['ProductID'] == uuid + assert data['ProductName'] == 'Defender' + assert data['Category'] == 'Cars' + assert data['Price'] == 40000.00 + + assert state.dirty == [] + + product.name = 'Toyota Carola' + product.price = 32500.00 + + data = dict(state.data_for_update()) + + assert data['ProductName'] == 'Toyota Carola' + assert data['Category'] == 'Cars' + assert data['Price'] == 32500.00 From c14b5f542ad3609433f8bd27bae35b372265b990 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 5 May 2021 11:20:48 +0200 Subject: [PATCH 29/57] Fix an OData state thingie --- odata/state.py | 524 +++++++++++++++++++++++++------------------------ 1 file changed, 265 insertions(+), 259 deletions(-) diff --git a/odata/state.py b/odata/state.py index dac0181..c7dd9e1 100644 --- a/odata/state.py +++ b/odata/state.py @@ -1,259 +1,265 @@ -# -*- coding: utf-8 -*- - -from __future__ import print_function -import os -import inspect -import logging -import re -from collections import OrderedDict - -from odata.property import PropertyBase, NavigationProperty -import odata - - -class EntityState(object): - - def __init__(self, entity): - self.log = logging.getLogger('odata.state') - """:type entity: EntityBase """ - self.entity = entity - self.dirty = [] - self.nav_cache = {} - self.data = OrderedDict() - self.connection = None - # does this object exist serverside - self.persisted = False - self.persisted_id = None - - # dictionary access - def __getitem__(self, item): - return self.data[item] - - def __setitem__(self, key, value): - self.data[key] = value - - def __contains__(self, item): - return item in self.data - - def get(self, key, default): - return self.data.get(key, default) - - def update(self, other): - self.data.update(other) - # /dictionary access - - def __repr__(self): - return self.data.__repr__() - - def describe(self): - rows = [ - u'EntitySet: {0}'.format(self.entity.__odata_collection__), - u'Type: {0}'.format(self.entity.__odata_type__), - u'URL: {0}'.format(self.instance_url or self.entity.__odata_url__()), - u'', - u'Properties', - u'-' * 40, - ] - - for _, prop in self.properties: - name = prop.name - if prop.primary_key: - name += '*' - if prop.name in self.dirty: - name += ' (dirty)' - rows.append(name) - - rows.append(u'') - rows.append(u'Navigation Properties') - rows.append(u'-' * 40) - - for _, prop in self.navigation_properties: - rows.append(prop.name) - - rows = os.linesep.join(rows) - print(rows) - - def reset(self): - self.dirty = [] - self.nav_cache = {} - self.persisted_id = None - - @property - def id(self): - if self.persisted_id: - return self.persisted_id - ids = [] - entity_name = self.entity.__odata_collection__ - if entity_name is None: - return - - for prop_name, prop in self.primary_key_properties: - value = self.data.get(prop.name) - if value is not None: - ids.append((prop, str(prop.escape_value(value)))) - if len(ids) == 1: - key_value = ids[0][1] - return u'{0}({1})'.format(entity_name, - key_value) - if len(ids) > 1: - key_ids = [] - for prop, key_value in ids: - key_ids.append('{0}={1}'.format(prop.name, key_value)) - return u'{0}({1})'.format(entity_name, ','.join(key_ids)) - - @property - def instance_url(self): - if self.id: - url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) - return url + self.id - - @property - def properties(self): - props = [] - cls = self.entity.__class__ - for key, value in inspect.getmembers(cls): - if isinstance(value, PropertyBase): - props.append((key, value)) - return props - - @property - def primary_key_properties(self): - pks = [] - for prop_name, prop in self.properties: - if prop.primary_key is True: - pks.append((prop_name, prop)) - return pks - - @property - def navigation_properties(self): - props = [] - cls = self.entity.__class__ - for key, value in inspect.getmembers(cls): - if isinstance(value, NavigationProperty): - props.append((key, value)) - return props - - @property - def dirty_properties(self): - rv = [] - for prop_name, prop in self.properties: - if prop.name in self.dirty: - rv.append((prop_name, prop)) - return rv - - def set_property_dirty(self, prop): - if prop.name not in self.dirty: - self.dirty.append(prop.name) - - def data_for_insert(self): - return self._new_entity(self.entity) - - def data_for_update(self): - return self._updated_entity(self.entity) - - def _new_entity(self, entity): - """:type entity: odata.entity.EntityBase """ - insert_data = OrderedDict() - insert_data['@odata.type'] = entity.__odata_type__ - - es = entity.__odata__ - - for _, prop in es.properties: - if prop.is_computed_value: - continue - - insert_data[prop.name] = es[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: - insert_data.pop(pk_prop.name) - - # Deep insert from nav properties - for prop_name, prop in es.navigation_properties: - - value = getattr(entity, prop_name, None) - """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" - insert_data = self._add_or_update_associated(insert_data, prop, value) - - for _, prop in es.properties: - if prop.name in insert_data: - if not insert_data[prop.name]: - insert_data.pop(prop.name) - - return insert_data - - def _updated_entity(self, entity): - update_data = OrderedDict() - update_data['@odata.type'] = self.entity.__odata_type__ - - es = entity.__odata__ - - for _, prop in es.dirty_properties: - if prop.is_computed_value: - continue - - update_data[prop.name] = es[prop.name] - - for prop_name, prop in es.navigation_properties: - if prop.name in es.dirty: - value = getattr(entity, prop_name, None) # get the related object - """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" - update_data = self._add_or_update_associated(update_data, prop, value) - - return update_data - - def _add_or_update_associated(self, data, prop, value): - if value is None: - return data - if prop.is_collection: - data = self._add_or_update_associated_collection(data, prop, value) - else: - data = self._add_or_update_associated_instance(data, prop, value) - return data - - def _add_or_update_associated_collection(self, data, prop, value): - - def is_new(entity): - if entity.__odata__.id is None: - return True - return False - - def is_dirty(entity): - if is_new(entity): - return False - elif hasattr(entity.__odata__, 'dirty') and entity.__odata__.dirty: - return True - return False - - def is_persisted(entity): - return (not is_new(entity) and not is_dirty(entity)) - - ids = [i.__odata__.id for i in value if is_persisted(i)] - if ids: - data['{0}@odata.bind'.format(prop.name)] = ids - - upd_objs = [self._updated_entity(i) for i in value if is_dirty(i)] - - new_objs = [self._new_entity(i) for i in value if is_new(i)] - - if upd_objs or new_objs: - data[prop.name] = upd_objs + new_objs - - return data - - def _add_or_update_associated_instance(self, data, prop, value): - if isinstance(value, odata.entity.EntityBase): - if value.persisted is False: - data[prop.name] = self._new_entity(value) - - elif value.dirty: - data[prop.name] = self._updated_entity(value) - - elif value.__odata__.id: - data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id - - elif value.__odata__.id: - data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id - - return data +# -*- coding: utf-8 -*- + +from __future__ import print_function +import os +import inspect +import logging +import re +from collections import OrderedDict + +from odata.property import PropertyBase, NavigationProperty +import odata + + +class EntityState(object): + + def __init__(self, entity): + self.log = logging.getLogger('odata.state') + """:type entity: EntityBase """ + self.entity = entity + self.dirty = [] + self.nav_cache = {} + self.data = OrderedDict() + self.connection = None + # does this object exist serverside + self.persisted = False + self.persisted_id = None + + # dictionary access + def __getitem__(self, item): + return self.data[item] + + def __setitem__(self, key, value): + self.data[key] = value + + def __contains__(self, item): + return item in self.data + + def get(self, key, default): + return self.data.get(key, default) + + def update(self, other): + self.data.update(other) + # /dictionary access + + def __repr__(self): + return self.data.__repr__() + + def describe(self): + rows = [ + u'EntitySet: {0}'.format(self.entity.__odata_collection__), + u'Type: {0}'.format(self.entity.__odata_type__), + u'URL: {0}'.format(self.instance_url or self.entity.__odata_url__()), + u'', + u'Properties', + u'-' * 40, + ] + + for _, prop in self.properties: + name = prop.name + if prop.primary_key: + name += '*' + if prop.name in self.dirty: + name += ' (dirty)' + rows.append(name) + + rows.append(u'') + rows.append(u'Navigation Properties') + rows.append(u'-' * 40) + + for _, prop in self.navigation_properties: + rows.append(prop.name) + + rows = os.linesep.join(rows) + print(rows) + + def reset(self): + self.dirty = [] + self.nav_cache = {} + self.persisted_id = None + + @property + def id(self): + if self.persisted_id: + return self.persisted_id + ids = [] + entity_name = self.entity.__odata_collection__ + if entity_name is None: + return + + for prop_name, prop in self.primary_key_properties: + value = self.data.get(prop.name) + if value is not None: + ids.append((prop, str(prop.escape_value(value)))) + if len(ids) == 1: + key_value = ids[0][1] + return u'{0}({1})'.format(entity_name, + key_value) + if len(ids) > 1: + key_ids = [] + for prop, key_value in ids: + key_ids.append('{0}={1}'.format(prop.name, key_value)) + return u'{0}({1})'.format(entity_name, ','.join(key_ids)) + + @property + def instance_url(self): + if self.id: + url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) + return url + self.id + + @property + def properties(self): + props = [] + cls = self.entity.__class__ + for key, value in inspect.getmembers(cls): + if isinstance(value, PropertyBase): + props.append((key, value)) + return props + + @property + def primary_key_properties(self): + pks = [] + for prop_name, prop in self.properties: + if prop.primary_key is True: + pks.append((prop_name, prop)) + return pks + + @property + def navigation_properties(self): + props = [] + cls = self.entity.__class__ + for key, value in inspect.getmembers(cls): + if isinstance(value, NavigationProperty): + props.append((key, value)) + return props + + @property + def dirty_properties(self): + rv = [] + for prop_name, prop in self.properties: + if prop.name in self.dirty: + rv.append((prop_name, prop)) + return rv + + def set_property_dirty(self, prop): + if prop.name not in self.dirty: + self.dirty.append(prop.name) + + def data_for_insert(self): + return self._new_entity(self.entity) + + def data_for_update(self): + return self._updated_entity(self.entity) + + def _new_entity(self, entity): + """:type entity: odata.entity.EntityBase """ + insert_data = OrderedDict() + insert_data['@odata.type'] = entity.__odata_type__ + + es = entity.__odata__ + + for _, prop in es.properties: + if prop.is_computed_value: + continue + + insert_data[prop.name] = es[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: + insert_data.pop(pk_prop.name) + + # Deep insert from nav properties + for prop_name, prop in es.navigation_properties: + + value = getattr(entity, prop_name, None) + """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" + insert_data = self._add_or_update_associated(insert_data, prop, value) + + for _, prop in es.properties: + if prop.name in insert_data: + if not insert_data[prop.name]: + insert_data.pop(prop.name) + + return insert_data + + def _updated_entity(self, entity): + update_data = OrderedDict() + update_data['@odata.type'] = self.entity.__odata_type__ + + es = entity.__odata__ + + for _, pk_prop in es.primary_key_properties: + update_data[pk_prop.name] = es[pk_prop.name] + + if '@odata.etag' in es: + update_data['@odata.etag'] = es['@odata.etag'] + + for _, prop in es.dirty_properties: + if prop.is_computed_value: + continue + + update_data[prop.name] = es[prop.name] + + for prop_name, prop in es.navigation_properties: + if prop.name in es.dirty: + value = getattr(entity, prop_name, None) # get the related object + """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" + update_data = self._add_or_update_associated(update_data, prop, value) + + return update_data + + def _add_or_update_associated(self, data, prop, value): + if value is None: + return data + if prop.is_collection: + data = self._add_or_update_associated_collection(data, prop, value) + else: + data = self._add_or_update_associated_instance(data, prop, value) + return data + + def _add_or_update_associated_collection(self, data, prop, value): + + def is_new(entity): + if entity.__odata__.id is None: + return True + return False + + def is_dirty(entity): + if is_new(entity): + return False + elif hasattr(entity.__odata__, 'dirty') and entity.__odata__.dirty: + return True + return False + + def is_persisted(entity): + return (not is_new(entity) and not is_dirty(entity)) + + ids = [i.__odata__.id for i in value if is_persisted(i)] + if ids: + data['{0}@odata.bind'.format(prop.name)] = ids + + upd_objs = [self._updated_entity(i) for i in value if is_dirty(i)] + + new_objs = [self._new_entity(i) for i in value if is_new(i)] + + if upd_objs or new_objs: + data[prop.name] = upd_objs + new_objs + + return data + + def _add_or_update_associated_instance(self, data, prop, value): + if isinstance(value, odata.entity.EntityBase): + if value.persisted is False: + data[prop.name] = self._new_entity(value) + + elif value.dirty: + data[prop.name] = self._updated_entity(value) + + elif value.__odata__.id: + data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id + + elif value.__odata__.id: + data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id + + return data From f7b194995e2574bcebd2512608227a3c1e3471c1 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 11 Oct 2021 08:24:54 +0200 Subject: [PATCH 30/57] Also log response for debug level --- odata/connection.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/odata/connection.py b/odata/connection.py index c135bc2..cd7a380 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -78,6 +78,7 @@ def _handle_odata_error(self, response): response_ct = response.headers.get('content-type', '') if 'application/json' in response_ct: + self.log.debug(u'JSON: {0}'.format(response.json())) errordata = response.json() if 'error' in errordata: @@ -118,6 +119,7 @@ def execute_get(self, url, params=None): if response.status_code == requests.codes.no_content: return if 'application/json' in response_ct: + self.log.debug(u'JSON: {0}'.format(response.json())) return response.json() else: msg = u'Unsupported response Content-Type: {0}'.format(response_ct) @@ -148,6 +150,7 @@ def execute_post(self, url, data, params=None): if response.status_code == requests.codes.no_content: return if 'application/json' in response_ct: + self.log.debug(u'JSON: {0}'.format(response.json())) return response.json() # no exceptions here, POSTing to Actions may not return data except: @@ -172,6 +175,9 @@ def execute_patch(self, url, data): response = self._do_patch(url, data=data, headers=headers) self._handle_odata_error(response) + response_ct = response.headers.get('content-type', '') + if 'application/json' in response_ct: + self.log.debug(u'JSON: {0}'.format(response.json())) except: raise finally: @@ -189,6 +195,9 @@ def execute_delete(self, url): response = self._do_delete(url, headers=headers) self._handle_odata_error(response) + response_ct = response.headers.get('content-type', '') + if 'application/json' in response_ct: + self.log.debug(u'JSON: {0}'.format(response.json())) except: raise finally: From 109e902d7dee2d554b93bc07de2f5839d48eb060 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 11 Oct 2021 08:30:19 +0200 Subject: [PATCH 31/57] With full metadata --- odata/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/connection.py b/odata/connection.py index cd7a380..326572f 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -24,7 +24,7 @@ def inner(*args, **kwargs): class ODataConnection(object): base_headers = { - 'Accept': 'application/json', + 'Accept': 'application/json; odata.metadata=full', 'OData-Version': '4.0', 'User-Agent': 'python-odata {0}'.format(version), } From f4ba4c870f51fec566a9cf13ae07bb330da8a8fe Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 12 Oct 2021 10:42:21 +0200 Subject: [PATCH 32/57] metadata values --- odata/entity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/odata/entity.py b/odata/entity.py index a97e441..25efa22 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -131,6 +131,10 @@ def __new__(cls, *args, **kwargs): if 'from_data' in kwargs: raw_data = kwargs.pop('from_data') + for k, v in raw_data.items(): + if '@odata' in k: + i.__odata__[k] = v + # check for values from $expand for prop_name, prop in es.navigation_properties: if prop.name in raw_data: From 74b152034f622af22028eed79e70e7f5a73ecfff Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 14 Oct 2021 15:07:05 +0200 Subject: [PATCH 33/57] Graceful --- odata/entity.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/odata/entity.py b/odata/entity.py index 25efa22..58096fb 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -131,9 +131,10 @@ def __new__(cls, *args, **kwargs): if 'from_data' in kwargs: raw_data = kwargs.pop('from_data') - for k, v in raw_data.items(): - if '@odata' in k: - i.__odata__[k] = v + if raw_data: + for k, v in raw_data.items(): + if '@odata' in k: + i.__odata__[k] = v # check for values from $expand for prop_name, prop in es.navigation_properties: From 839dc1e1d0bdd55e9cbbda272964d732d13b9ab7 Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 14 Oct 2021 15:11:25 +0200 Subject: [PATCH 34/57] gracefull --- odata/entity.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/odata/entity.py b/odata/entity.py index 58096fb..6da89a3 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -136,17 +136,17 @@ def __new__(cls, *args, **kwargs): if '@odata' in k: i.__odata__[k] = v - # check for values from $expand - 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) + # check for values from $expand + 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 = True i.__odata__.persisted_id = i.__odata__.id From cd199b1771bc83b932c29d363bd3972ed1df8b93 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 25 Oct 2021 10:44:40 +0200 Subject: [PATCH 35/57] urling --- odata/navproperty.py | 2 ++ odata/state.py | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/odata/navproperty.py b/odata/navproperty.py index 4c5527c..f9d4f38 100644 --- a/odata/navproperty.py +++ b/odata/navproperty.py @@ -110,6 +110,7 @@ def __get__(self, instance, owner): cache['collection'] = self.instances_from_data(raw_data['value']) else: cache['collection'] = [] + [c.__odata__.set_scope(url) for c in cache['collection'] if c] return cache['collection'] else: if 'single' not in cache: @@ -118,4 +119,5 @@ def __get__(self, instance, owner): cache['single'] = self.instances_from_data(raw_data) else: cache['single'] = None + cache['single'].__odata__.set_scope(url) if cache['single'] return cache['single'] diff --git a/odata/state.py b/odata/state.py index c7dd9e1..71f0617 100644 --- a/odata/state.py +++ b/odata/state.py @@ -6,6 +6,12 @@ import logging import re from collections import OrderedDict +try: + # noinspection PyUnresolvedReferences + from urllib.parse import urljoin +except ImportError: + # noinspection PyUnresolvedReferences + from urlparse import urljoin from odata.property import PropertyBase, NavigationProperty import odata @@ -24,6 +30,7 @@ def __init__(self, entity): # does this object exist serverside self.persisted = False self.persisted_id = None + self.odata_scope = None # dictionary access def __getitem__(self, item): @@ -104,8 +111,15 @@ def id(self): @property def instance_url(self): if self.id: - url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) - return url + self.id + odata_id = self.get('@odata.id', None) + if self.odata_scope: + url = re.sub(self.entity.__odata_collection__, '', self.odata_scope) + return urljoin(url, self.id) + elif odata_id and self.id in odata_id: + return urljoin(self.entity.__odata_service__.url, odata_id) + else: + url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) + return urljoin(url, self.id) @property def properties(self): @@ -151,6 +165,10 @@ def data_for_insert(self): def data_for_update(self): return self._updated_entity(self.entity) + def set_scope(self, odata_scope): + if odata_scope: + self.odata_scope = odata_scope + def _new_entity(self, entity): """:type entity: odata.entity.EntityBase """ insert_data = OrderedDict() From 49a1990c89d858073f675affee9b1f2bf6d0f70c Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 25 Oct 2021 10:48:21 +0200 Subject: [PATCH 36/57] url --- odata/navproperty.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/odata/navproperty.py b/odata/navproperty.py index f9d4f38..f8c1eda 100644 --- a/odata/navproperty.py +++ b/odata/navproperty.py @@ -119,5 +119,6 @@ def __get__(self, instance, owner): cache['single'] = self.instances_from_data(raw_data) else: cache['single'] = None - cache['single'].__odata__.set_scope(url) if cache['single'] + if cache['single'] + cache['single'].__odata__.set_scope(url) return cache['single'] From 293bfb233c0e61e709e1e49e1d6d7683f3b0113d Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 25 Oct 2021 10:49:10 +0200 Subject: [PATCH 37/57] url --- odata/navproperty.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/navproperty.py b/odata/navproperty.py index f8c1eda..3507311 100644 --- a/odata/navproperty.py +++ b/odata/navproperty.py @@ -119,6 +119,6 @@ def __get__(self, instance, owner): cache['single'] = self.instances_from_data(raw_data) else: cache['single'] = None - if cache['single'] + if cache['single']: cache['single'].__odata__.set_scope(url) return cache['single'] From b5a366ba97304aa1b5396a6c53a9857d6068fcfe Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 25 Oct 2021 11:27:42 +0200 Subject: [PATCH 38/57] navigation --- odata/entity.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/odata/entity.py b/odata/entity.py index 6da89a3..174382e 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -151,6 +151,13 @@ def __new__(cls, *args, **kwargs): i.__odata__.persisted = True i.__odata__.persisted_id = i.__odata__.id else: + for prop_name, prop in es.navigation_properties: + if prop_name in data.keys(): + if prop.is_collection: + es.nav_cache[prop.name] = dict(collection=prop.instances_from_data(data[prop_name])) + else: + es.nav_cache[prop.name] = dict(single=prop.instances_from_data(data[prop_name])) + for prop_name, prop in es.properties: if prop_name in data.keys(): i.__odata__[prop.name] = data[prop_name] From bc6fdc1fb6faa6dfc6c68ee513b86887681a71f6 Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 26 Oct 2021 08:43:44 +0200 Subject: [PATCH 39/57] urling --- odata/state.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/odata/state.py b/odata/state.py index 71f0617..454f13d 100644 --- a/odata/state.py +++ b/odata/state.py @@ -110,16 +110,23 @@ def id(self): @property def instance_url(self): + odata_id = self.get('@odata.id', None) if self.id: - odata_id = self.get('@odata.id', None) if self.odata_scope: - url = re.sub(self.entity.__odata_collection__, '', self.odata_scope) - return urljoin(url, self.id) + if self.odata_scope.endswith(self.entity.__odata_collection__): + url = re.sub(self.entity.__odata_collection__, '', self.odata_scope) + return urljoin(url, self.id) + else: + return self.odata_scope elif odata_id and self.id in odata_id: return urljoin(self.entity.__odata_service__.url, odata_id) else: url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) return urljoin(url, self.id) + elif odata_id and odata_id.startswith('http'): + return odata_id + elif odata_id: + return urljoin(self.entity.__odata_service__.url, odata_id) @property def properties(self): From d2986a528579f1203c883bff61261df088d10bf8 Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 26 Oct 2021 08:58:24 +0200 Subject: [PATCH 40/57] urling --- odata/state.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/odata/state.py b/odata/state.py index 454f13d..7261eb6 100644 --- a/odata/state.py +++ b/odata/state.py @@ -8,10 +8,10 @@ from collections import OrderedDict try: # noinspection PyUnresolvedReferences - from urllib.parse import urljoin + from urllib.parse import urljoin, urlparse except ImportError: # noinspection PyUnresolvedReferences - from urlparse import urljoin + from urlparse import urljoin, urlparse from odata.property import PropertyBase, NavigationProperty import odata @@ -124,7 +124,8 @@ def instance_url(self): url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) return urljoin(url, self.id) elif odata_id and odata_id.startswith('http'): - return odata_id + odata_id = urlparse(odata_id).path.split('/')[-1] + return urljoin(self.entity.__odata_service__.url, odata_id) elif odata_id: return urljoin(self.entity.__odata_service__.url, odata_id) From 2c77e366e6a17d61e04eb32ca912fa6a7791b00c Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 26 Oct 2021 09:01:12 +0200 Subject: [PATCH 41/57] urling --- odata/state.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index 7261eb6..03401d6 100644 --- a/odata/state.py +++ b/odata/state.py @@ -119,7 +119,11 @@ def instance_url(self): else: return self.odata_scope elif odata_id and self.id in odata_id: - return urljoin(self.entity.__odata_service__.url, odata_id) + if odata_id.startswith('http'): + odata_id = urlparse(odata_id).path.split('/')[-1] + return urljoin(self.entity.__odata_service__.url, odata_id) + else: + return urljoin(self.entity.__odata_service__.url, odata_id) else: url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) return urljoin(url, self.id) From 620c93d4ce23b8c2550744a645eaceabd8d1347f Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 26 Oct 2021 09:08:11 +0200 Subject: [PATCH 42/57] urling --- odata/state.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/odata/state.py b/odata/state.py index 03401d6..b246883 100644 --- a/odata/state.py +++ b/odata/state.py @@ -119,11 +119,12 @@ def instance_url(self): else: return self.odata_scope elif odata_id and self.id in odata_id: + url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) if odata_id.startswith('http'): odata_id = urlparse(odata_id).path.split('/')[-1] - return urljoin(self.entity.__odata_service__.url, odata_id) + return urljoin(url, odata_id) else: - return urljoin(self.entity.__odata_service__.url, odata_id) + return urljoin(url, odata_id) else: url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) return urljoin(url, self.id) From 8cb30c2f54031d63b43b5e0f23cbed26254c1945 Mon Sep 17 00:00:00 2001 From: jarra Date: Tue, 26 Oct 2021 09:10:57 +0200 Subject: [PATCH 43/57] urling --- odata/state.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/odata/state.py b/odata/state.py index b246883..ec41bde 100644 --- a/odata/state.py +++ b/odata/state.py @@ -120,19 +120,15 @@ def instance_url(self): return self.odata_scope elif odata_id and self.id in odata_id: url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) - if odata_id.startswith('http'): - odata_id = urlparse(odata_id).path.split('/')[-1] - return urljoin(url, odata_id) - else: - return urljoin(url, odata_id) + odata_id = odata_id.split('/')[-1] + return urljoin(url, odata_id) else: url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) return urljoin(url, self.id) - elif odata_id and odata_id.startswith('http'): - odata_id = urlparse(odata_id).path.split('/')[-1] - return urljoin(self.entity.__odata_service__.url, odata_id) elif odata_id: - return urljoin(self.entity.__odata_service__.url, odata_id) + url = re.sub(self.entity.__odata_collection__, '', self.entity.__odata_url__()) + odata_id = odata_id.split('/')[-1] + return urljoin(url, odata_id) @property def properties(self): From 3c5796df7e4330dd0554ef04ccabedbf8e8cf736 Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 28 Oct 2021 15:03:47 +0200 Subject: [PATCH 44/57] Bugfix --- odata/state.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/odata/state.py b/odata/state.py index ec41bde..ddc0d97 100644 --- a/odata/state.py +++ b/odata/state.py @@ -162,6 +162,9 @@ def dirty_properties(self): for prop_name, prop in self.properties: if prop.name in self.dirty: rv.append((prop_name, prop)) + for prop_name, prop in self.navigation_properties: + if prop.name in self.dirty: + rv.append((prop_name, prop)) return rv def set_property_dirty(self, prop): From b45f0f13542f6ab77a402acf6ebdc02558bab138 Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 28 Oct 2021 15:06:29 +0200 Subject: [PATCH 45/57] Bugfix --- odata/navproperty.py | 1 + 1 file changed, 1 insertion(+) diff --git a/odata/navproperty.py b/odata/navproperty.py index 3507311..2fae216 100644 --- a/odata/navproperty.py +++ b/odata/navproperty.py @@ -47,6 +47,7 @@ def __init__(self, name, entitycls, collection=False, foreign_key=None): self.name = name self.entitycls = entitycls self.is_collection = collection + self.is_computed_value = False if isinstance(foreign_key, PropertyBase): self.foreign_key = foreign_key.name else: From aa30eafead601c0417c5088764ddba90c81f96b8 Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 28 Oct 2021 15:12:30 +0200 Subject: [PATCH 46/57] Bugfix --- odata/state.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/odata/state.py b/odata/state.py index ddc0d97..9eb2bd5 100644 --- a/odata/state.py +++ b/odata/state.py @@ -228,6 +228,8 @@ def _updated_entity(self, entity): for _, prop in es.dirty_properties: if prop.is_computed_value: continue + if prop.name in es.navigation_properties: + continue update_data[prop.name] = es[prop.name] From f47ce294407afa9b66159d32422ae93f999ed102 Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 28 Oct 2021 15:14:16 +0200 Subject: [PATCH 47/57] bugfix --- odata/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index 9eb2bd5..b4950b0 100644 --- a/odata/state.py +++ b/odata/state.py @@ -228,7 +228,7 @@ def _updated_entity(self, entity): for _, prop in es.dirty_properties: if prop.is_computed_value: continue - if prop.name in es.navigation_properties: + if prop.name in es.navigation_properties.keys(): continue update_data[prop.name] = es[prop.name] From b84e5d6e821ec55d44d9a1ceccbb2499bd14ed47 Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 28 Oct 2021 15:16:14 +0200 Subject: [PATCH 48/57] bugfix --- odata/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index b4950b0..270f3e7 100644 --- a/odata/state.py +++ b/odata/state.py @@ -228,7 +228,7 @@ def _updated_entity(self, entity): for _, prop in es.dirty_properties: if prop.is_computed_value: continue - if prop.name in es.navigation_properties.keys(): + if prop.name in dict(es.navigation_properties.keys()): continue update_data[prop.name] = es[prop.name] From 58da7bd48cbb32bd8eec70f006783c0b97f1b552 Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 28 Oct 2021 16:41:20 +0200 Subject: [PATCH 49/57] Scoped inserts --- odata/context.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/odata/context.py b/odata/context.py index 09fe8ef..38407e4 100644 --- a/odata/context.py +++ b/odata/context.py @@ -94,7 +94,11 @@ def _insert_new(self, entity): :type entity: EntityBase """ - url = entity.__odata_url__() + + if entity.__odata__.odata_scope: + url = entity.__odata__.odata_scope + else: + url = entity.__odata_url__() if url is None: msg = 'Cannot insert Entity that does not belong to EntitySet: {0}'.format(entity) raise ODataError(msg) From 4ed11f26ec0b2a70fe6d8bd9869395cde31fb8de Mon Sep 17 00:00:00 2001 From: jarra Date: Thu, 28 Oct 2021 16:43:34 +0200 Subject: [PATCH 50/57] Fix --- odata/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index 270f3e7..612198f 100644 --- a/odata/state.py +++ b/odata/state.py @@ -228,7 +228,7 @@ def _updated_entity(self, entity): for _, prop in es.dirty_properties: if prop.is_computed_value: continue - if prop.name in dict(es.navigation_properties.keys()): + if prop.name in dict(es.navigation_properties).keys(): continue update_data[prop.name] = es[prop.name] From f6f1e74e84ceb9d3b095f7d2f9e70b064a940750 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 8 Nov 2021 10:35:42 +0100 Subject: [PATCH 51/57] Bugfix --- odata/state.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/odata/state.py b/odata/state.py index 612198f..fe8abc6 100644 --- a/odata/state.py +++ b/odata/state.py @@ -98,6 +98,8 @@ def id(self): value = self.data.get(prop.name) if value is not None: ids.append((prop, str(prop.escape_value(value)))) + else: + return if len(ids) == 1: key_value = ids[0][1] return u'{0}({1})'.format(entity_name, From b9d52ac74c226696c2747212c83b446bef86ec1a Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 8 Nov 2021 11:21:23 +0100 Subject: [PATCH 52/57] fix --- odata/connection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/odata/connection.py b/odata/connection.py index 326572f..8bbf600 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -178,6 +178,7 @@ def execute_patch(self, url, data): response_ct = response.headers.get('content-type', '') if 'application/json' in response_ct: self.log.debug(u'JSON: {0}'.format(response.json())) + return response.json() except: raise finally: From 80696eb5f9f62e8f62267f66a3d375128866ca91 Mon Sep 17 00:00:00 2001 From: jarra Date: Mon, 8 Nov 2021 13:14:06 +0100 Subject: [PATCH 53/57] fix --- odata/state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index fe8abc6..d87bf07 100644 --- a/odata/state.py +++ b/odata/state.py @@ -87,7 +87,7 @@ def reset(self): @property def id(self): - if self.persisted_id: + if self.persisted and self.persisted_id: return self.persisted_id ids = [] entity_name = self.entity.__odata_collection__ @@ -100,6 +100,7 @@ def id(self): ids.append((prop, str(prop.escape_value(value)))) else: return + if len(ids) == 1: key_value = ids[0][1] return u'{0}({1})'.format(entity_name, From 8a8f88329ca0f5b893e114bcf7ab02f3a8106ef0 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 15 Dec 2021 14:44:00 +0100 Subject: [PATCH 54/57] Fixing relations --- odata/entity.py | 6 +++++- odata/state.py | 11 +++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/odata/entity.py b/odata/entity.py index 174382e..8388bde 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from typing import Dict """ Entity classes @@ -156,7 +157,10 @@ def __new__(cls, *args, **kwargs): if prop.is_collection: es.nav_cache[prop.name] = dict(collection=prop.instances_from_data(data[prop_name])) else: - es.nav_cache[prop.name] = dict(single=prop.instances_from_data(data[prop_name])) + if isinstance(data[prop_name], Dict): + es.nav_cache[prop.name] = dict(single=prop.instances_from_data(data[prop_name])) + else: + es.nav_cache[prop.name] = dict(single=data[prop_name]) for prop_name, prop in es.properties: if prop_name in data.keys(): diff --git a/odata/state.py b/odata/state.py index d87bf07..8fcbfa5 100644 --- a/odata/state.py +++ b/odata/state.py @@ -204,7 +204,6 @@ def _new_entity(self, entity): # Deep insert from nav properties for prop_name, prop in es.navigation_properties: - value = getattr(entity, prop_name, None) """:type : None | odata.entity.EntityBase | list[odata.entity.EntityBase]""" insert_data = self._add_or_update_associated(insert_data, prop, value) @@ -270,7 +269,7 @@ def is_dirty(entity): def is_persisted(entity): return (not is_new(entity) and not is_dirty(entity)) - ids = [i.__odata__.id for i in value if is_persisted(i)] + ids = ['/' + i.__odata__.id for i in value if is_persisted(i)] if ids: data['{0}@odata.bind'.format(prop.name)] = ids @@ -288,13 +287,13 @@ def _add_or_update_associated_instance(self, data, prop, value): if value.persisted is False: data[prop.name] = self._new_entity(value) + elif value.__odata__.id: + data['{0}@odata.bind'.format(prop.name)] = '/' + value.__odata__.id + elif value.dirty: data[prop.name] = self._updated_entity(value) - elif value.__odata__.id: - data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id - elif value.__odata__.id: - data['{0}@odata.bind'.format(prop.name)] = value.__odata__.id + data['{0}@odata.bind'.format(prop.name)] = '/' + value.__odata__.id return data From e5689e4727e4f117a10d730032018541827de0c1 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 12 Jan 2022 12:57:12 +0100 Subject: [PATCH 55/57] This might break things? --- odata/connection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/odata/connection.py b/odata/connection.py index 8bbf600..2d3b9e6 100644 --- a/odata/connection.py +++ b/odata/connection.py @@ -24,7 +24,6 @@ def inner(*args, **kwargs): class ODataConnection(object): base_headers = { - 'Accept': 'application/json; odata.metadata=full', 'OData-Version': '4.0', 'User-Agent': 'python-odata {0}'.format(version), } From 6bbb8bc9c2f2d9c3ba59bbec43499dc1c0542466 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 18 May 2022 09:45:48 +0200 Subject: [PATCH 56/57] Bugfix --- odata/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index 8fcbfa5..6092c62 100644 --- a/odata/state.py +++ b/odata/state.py @@ -97,7 +97,7 @@ def id(self): for prop_name, prop in self.primary_key_properties: value = self.data.get(prop.name) if value is not None: - ids.append((prop, str(prop.escape_value(value)))) + ids.append((prop, str(prop.escape_value(str(value))))) else: return From b79a43e90d916a92f3e517edb3198ccec5120905 Mon Sep 17 00:00:00 2001 From: jarra Date: Wed, 18 May 2022 10:22:12 +0200 Subject: [PATCH 57/57] Bugfix --- odata/state.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/odata/state.py b/odata/state.py index 6092c62..ce70e75 100644 --- a/odata/state.py +++ b/odata/state.py @@ -6,6 +6,7 @@ import logging import re from collections import OrderedDict + try: # noinspection PyUnresolvedReferences from urllib.parse import urljoin, urlparse @@ -97,7 +98,10 @@ def id(self): for prop_name, prop in self.primary_key_properties: value = self.data.get(prop.name) if value is not None: - ids.append((prop, str(prop.escape_value(str(value))))) + if isinstance(value, str): + ids.append((prop, str(prop.escape_value(value)))) + else: + ids.append((prop, value)) else: return