diff --git a/README.md b/README.md index e6365b2..4281718 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ Available on [readthedocs.org](http://tuomur-python-odata.readthedocs.org/en/lat ## Dependencies - requests >= 2.0 +- responses - python-dateutil + ## Demo Reading data from the Northwind service. diff --git a/odata/context.py b/odata/context.py index ce9a5ab..3943e61 100644 --- a/odata/context.py +++ b/odata/context.py @@ -3,6 +3,7 @@ import logging from odata.query import Query +from odata.querysingle import QuerySingle from odata.connection import ODataConnection @@ -16,6 +17,10 @@ def query(self, entitycls): q = Query(entitycls, connection=self.connection) return q + def querySingle(self, entitycls): + q = QuerySingle(entitycls, connection=self.connection) + return q + def call(self, action_or_function, **parameters): """ Call a defined Action or Function using this Context's connection diff --git a/odata/entity.py b/odata/entity.py index fabfecf..1d3b31a 100644 --- a/odata/entity.py +++ b/odata/entity.py @@ -96,6 +96,12 @@ def __odata_url__(cls): # used by Query if cls.__odata_collection__: return urljoin(cls.__odata_service__.url, cls.__odata_collection__) + + @classmethod + def __odata_single_url__(cls): + # used by Query + if cls.__odata_singleton__: + return urljoin(cls.__odata_service__.url, cls.__odata_singleton__) def __new__(cls, *args, **kwargs): i = super(EntityBase, cls).__new__(cls) diff --git a/odata/metadata.py b/odata/metadata.py index cc37b03..ee0443d 100644 --- a/odata/metadata.py +++ b/odata/metadata.py @@ -78,7 +78,7 @@ def _set_object_relationships(self, entities): ) setattr(entity, name, nav) - def _create_entities(self, all_types, entities, entity_sets, entity_base_class, schemas, depth=1): + def _create_entities(self, all_types, entities, entity_sets, singletons, entity_base_class, schemas, depth=1): orphan_entities = [] for schema in schemas: for entity_dict in schema.get('entities'): @@ -113,10 +113,13 @@ def _create_entities(self, all_types, entities, entity_sets, entity_base_class, else: entity_set = entity_sets.get(entity_type) or entity_sets.get(entity_type_alias) collection_name = (entity_set or {}).get('name') + singeton = singletons.get(entity_type) or singletons.get(entity_type_alias) + single_name = (singeton or {}).get('name') object_dict = dict( __odata_schema__=entity_dict, __odata_type__=entity_type, - __odata_collection__=collection_name + __odata_collection__=collection_name, + __odata_singleton__=single_name ) entity_class = type(entity_name, (entity_base_class,), object_dict) if collection_name: @@ -154,7 +157,7 @@ def _create_entities(self, all_types, entities, entity_sets, entity_base_class, 'Orphaned types: {0}').format(', '.join(orphan_entities)) raise ODataReflectionError(errmsg) depth += 1 - self._create_entities(all_types, entities, entity_sets, entity_base_class, schemas, depth) + self._create_entities(all_types, entities, entity_sets, singletons, entity_base_class, schemas, depth) def _create_actions(self, entities, actions, get_entity_or_prop_from_type): for action in actions: @@ -220,7 +223,7 @@ def _create_functions(self, entities, functions, get_entity_or_prop_from_type): def get_entity_sets(self, base=None): document = self.load_document() - schemas, entity_sets, actions, functions = self.parse_document(document) + schemas, entity_sets, singletons, actions, functions = self.parse_document(document) entities = {} base_class = base or declarative_base() @@ -242,7 +245,7 @@ def get_entity_or_prop_from_type(typename): created_enum = EnumType(enum_type['name'], names=names) all_types[enum_type['fully_qualified_name']] = created_enum - self._create_entities(all_types, entities, entity_sets, base_class, schemas) + self._create_entities(all_types, entities, entity_sets, singletons, base_class, schemas) self._set_object_relationships(entities) self._create_actions(entities, actions, get_entity_or_prop_from_type) self._create_functions(entities, functions, get_entity_or_prop_from_type) @@ -412,6 +415,7 @@ def _parse_enumtype(self, xmlq, enumtype_element, schema_name): def parse_document(self, doc): schemas = [] container_sets = {} + container_singletons = {} actions = [] functions = [] @@ -463,6 +467,24 @@ def xmlq(node, xpath): container_sets[set_type] = set_dict + for singleton in xmlq(schema, 'edm:EntityContainer/edm:Singleton'): + set_name = singleton.attrib['Name'] + set_type = singleton.attrib['Type'] + + set_dict = { + 'name': set_name, + 'type': set_type, + 'schema': None, + } + + for schema_ in schemas: + for entity in schema_.get('entities', []): + if set_type in (entity.get('type'), entity.get('type_alias')): + set_dict['schema'] = entity + break + + container_singletons[set_type] = set_dict + for action_def in xmlq(schema, 'edm:Action'): action = self._parse_action(xmlq, action_def, schema_name) actions.append(action) @@ -471,4 +493,4 @@ def xmlq(node, xpath): function = self._parse_function(xmlq, function_def, schema_name) functions.append(function) - return schemas, container_sets, actions, functions + return schemas, container_sets, container_singletons, actions, functions diff --git a/odata/querysingle.py b/odata/querysingle.py new file mode 100644 index 0000000..97b38ec --- /dev/null +++ b/odata/querysingle.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- + +""" +Querying +======== + +Entities can be queried from a service's singletons with a QuerySingle object: + +.. code-block:: python + + query = Service.querySingle(Order) + +Adding selects and other options always creates a new Query object with the +given directives: + +.. code-block:: python + + >>> query.filter(Order.Name == 'Foo') + > + +This makes object chaining possible: + +.. code-block:: python + + >>> first_order = query.filter(...).filter(...).order_by(...).first() + +The resulting objects can be fetched with :py:func:`~QuerySingle.get`. +Network is not accessed until this method is triggered. + +Navigation properties can be loaded in the same request with +:py:func:`~QuerySingle.expand`: + +.. code-block:: python + + >>> querySingle.expand(Order.Shipper, Order.Customer) + >>> order = querySingle.get() + +---- + +API +--- +""" + +try: + # noinspection PyUnresolvedReferences + from urllib.parse import urljoin +except ImportError: + # noinspection PyUnresolvedReferences + from urlparse import urljoin + +import odata.exceptions as exc + + +class QuerySingle(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): + self.entity = entitycls + self.options = options or dict() + self.connection = connection + + def get(self): + url = self._get_url() + options = self._get_options() + data = self.connection.execute_get(url, options) + return self._create_model(data) + + + def __repr__(self): + return ''.format(self.entity) + + def __str__(self): + return self.as_string() + + def _get_url(self): + return self.entity.__odata_single_url__() + + def _get_options(self): + """ + Format current query options to a dict that can be passed to requests + :return: Dictionary + """ + options = dict() + + _top = self.options.get('$top') + if _top is not None: + options['$top'] = _top + + _offset = self.options.get('$skip') + if _offset is not None: + options['$skip'] = _offset + + _select = self.options.get('$select') + if _select: + options['$select'] = ','.join(_select) + + _filters = self.options.get('$filter') + if _filters: + options['$filter'] = ' and '.join(_filters) + + _expand = self.options.get('$expand') + if _expand: + options['$expand'] = ','.join(_expand) + + _order_by = self.options.get('$orderby') + if _order_by: + options['$orderby'] = ','.join(_order_by) + return options + + def _create_model(self, row): + if len(self.options.get('$select', [])): + return row + else: + e = self.entity.__new__(self.entity, from_data=row) + es = e.__odata__ + es.connection = self.connection + return e + + def _get_or_create_option(self, name): + if name not in self.options: + self.options[name] = [] + return self.options[name] + + def _format_params(self, options): + return '&'.join(['='.join((key, str(value))) for key, value in options.items() if value is not None]) + + def _new_query(self): + """ + Create copy of this query without mutable values. All query builders + should use this first. + + :return: Query instance + """ + o = dict() + 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['$orderby'] = self.options.get('$orderby', [])[:] + return QuerySingle(self.entity, options=o, connection=self.connection) + + def as_string(self): + query = self._format_params(self._get_options()) + return urljoin(self._get_url(), '?{0}'.format(query)) + + # Query builders ########################################################### + + def select(self, *values): + """ + Set properties to fetch instead of full Entity objects + + :return: Raw JSON values for given properties + """ + q = self._new_query() + option = q._get_or_create_option('$select') + for prop in values: + option.append(prop.name) + return q + + def expand(self, *values): + """ + Set ``$expand`` query parameter + + :param values: ``Entity.Property`` instance + :return: Query instance + """ + q = self._new_query() + option = q._get_or_create_option('$expand') + for prop in values: + option.append(prop.name) + return q diff --git a/odata/service.py b/odata/service.py index 6238462..a5dd40f 100644 --- a/odata/service.py +++ b/odata/service.py @@ -179,6 +179,15 @@ def query(self, entitycls): """ return self.default_context.query(entitycls) + def querySingle(self, entitycls): + """ + Start a new query for given entity class + + :param entitycls: Entity to query + :return: Query object + """ + return self.default_context.querySingle(entitycls) + def delete(self, entity): """ Creates a DELETE call to the service, deleting the entity diff --git a/odata/tests/test_tp_reflect_model.py b/odata/tests/test_tp_reflect_model.py new file mode 100644 index 0000000..86516cc --- /dev/null +++ b/odata/tests/test_tp_reflect_model.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +import unittest + +from odata.service import ODataService + + +url = 'http://services.odata.org/TripPinRESTierService/' +Service = ODataService(url, reflect_entities=True) +Person = Service.entities.get('Person') +Product = Service.entities.get('Product') + + +class TripPinReflectModelReadTest(unittest.TestCase): + + def test_query_single(self): + s = Service.querySingle(Person) + data = s.get() + assert data is not None, 'data is None' + assert isinstance(data, Person), 'Did not return Person instance' + assert data.UserName == 'aprilcline' + + def test_query_raw_data(self): + q = Service.querySingle(Person) + q = q.select(Person.UserName) + data = q.get() + assert isinstance(data, dict), 'Did not return dict' + assert Person.UserName.name in data + \ No newline at end of file