From 3515a3698b983f1e8901c2297137206bb7fb4267 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Thu, 5 Sep 2019 10:35:47 +0200 Subject: [PATCH 01/36] Splits python representation of metadata and its parser The current implementation of python representation of metadata and metadata parser was tightly interconnected. Support for other versions of OData was not possible as in each version elements are added, removed or modified. Therefore, we decided to split metadata representation and its parser. With this approach, we can easily define supported elements and its parsing functions in a single class. This "configuration" class is stateless and has to be a child of ODATAVersion. Additional changes including updating directory structure and refactoring old code to accommodate for incoming ODATA V4 support. New module model: - builder -> MetadataBuilder was moved here to make code easier to read elements -> All EDM elements were moved here, to make python representation of elements version independent. All parsable elements have to inherit from "from_etree_mixin". - from_etree_callbacks -> All from_etree static methods were moved into separated function. This is a naive approach as its premise is that all from_etree implementations will be reusable in version V4. - types_traits -> "types traits" were moved here to make code cleaner and easier to read. Module V2: - __init__ -> includes OData2 definition. - service -> function-wise nothing has been changed. "Main" module: - config -> class Config was moved here to make it version and model-independent. In case we will ever need a config class also for service. Also ODataVersion class lives here. - policies -> All policies were moved here as well as ParserError enum. Again to make policies version and model-independent. Tests were only updated to incorporate new API. --- CHANGELOG.md | 1 + docs/usage/README.md | 2 +- docs/usage/initialization.rst | 5 +- pyodata/client.py | 58 +- pyodata/config.py | 117 ++ pyodata/model/__init__.py | 0 pyodata/model/builder.py | 151 +++ pyodata/{v2/model.py => model/elements.py} | 1132 +------------------- pyodata/model/from_etree_callbacks.py | 344 ++++++ pyodata/model/type_traits.py | 280 +++++ pyodata/policies.py | 48 + pyodata/v2/__init__.py | 296 +++++ pyodata/v2/service.py | 14 +- tests/conftest.py | 2 +- tests/test_client.py | 34 +- tests/test_model.py | 61 ++ tests/test_model_v2.py | 59 +- tests/test_service_v2.py | 1 - 18 files changed, 1444 insertions(+), 1161 deletions(-) create mode 100644 pyodata/config.py create mode 100644 pyodata/model/__init__.py create mode 100644 pyodata/model/builder.py rename pyodata/{v2/model.py => model/elements.py} (50%) create mode 100644 pyodata/model/from_etree_callbacks.py create mode 100644 pyodata/model/type_traits.py create mode 100644 pyodata/policies.py create mode 100644 tests/test_model.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0ae7be..5cb3ee2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Client can be created from local metadata - Jakub Filak - support all standard EDM schema versions - Jakub Filak +- Splits python representation of metadata and metadata parsing - Martin Miksik ### Fixed - make sure configured error policies are applied for Annotations referencing diff --git a/docs/usage/README.md b/docs/usage/README.md index d31f1722..ee76c611 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -1,4 +1,4 @@ -The User Guide +versionThe User Guide -------------- * [Initialization](initialization.rst) diff --git a/docs/usage/initialization.rst b/docs/usage/initialization.rst index fb673344..5bd37819 100644 --- a/docs/usage/initialization.rst +++ b/docs/usage/initialization.rst @@ -121,7 +121,9 @@ For parser to use your custom configuration, it needs to be passed as an argumen .. code-block:: python import pyodata - from pyodata.v2.model import PolicyFatal, PolicyWarning, PolicyIgnore, ParserError, Config + from pyodata.v2 import ODataV2 + from pyodata.policies import PolicyFatal, PolicyWarning, PolicyIgnore, ParserError + from pyodata.config import Config import requests SERVICE_URL = 'http://services.odata.org/V2/Northwind/Northwind.svc/' @@ -132,6 +134,7 @@ For parser to use your custom configuration, it needs to be passed as an argumen } custom_config = Config( + ODataV2, xml_namespaces=namespaces, default_error_policy=PolicyFatal(), custom_error_policies={ diff --git a/pyodata/client.py b/pyodata/client.py index a5e8b612..e1ce8d7d 100644 --- a/pyodata/client.py +++ b/pyodata/client.py @@ -3,9 +3,11 @@ import logging import warnings -import pyodata.v2.model -import pyodata.v2.service +from pyodata.config import Config +from pyodata.model.builder import MetadataBuilder from pyodata.exceptions import PyODataException, HttpError +from pyodata.v2.service import Service +from pyodata.v2 import ODataV2 def _fetch_metadata(connection, url, logger): @@ -34,43 +36,37 @@ class Client: """OData service client""" # pylint: disable=too-few-public-methods - - ODATA_VERSION_2 = 2 - - def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None, - config: pyodata.v2.model.Config = None, metadata: str = None): + def __new__(cls, url, connection, namespaces=None, + config: Config = None, metadata: str = None): """Create instance of the OData Client for given URL""" logger = logging.getLogger('pyodata.client') - if odata_version == Client.ODATA_VERSION_2: - - # sanitize url - url = url.rstrip('/') + '/' - - if metadata is None: - metadata = _fetch_metadata(connection, url, logger) - else: - logger.info('Using static metadata') + # sanitize url + url = url.rstrip('/') + '/' - if config is not None and namespaces is not None: - raise PyODataException('You cannot pass namespaces and config at the same time') + if metadata is None: + metadata = _fetch_metadata(connection, url, logger) + else: + logger.info('Using static metadata') - if config is None: - config = pyodata.v2.model.Config() + if config is not None and namespaces is not None: + raise PyODataException('You cannot pass namespaces and config at the same time') - if namespaces is not None: - warnings.warn("Passing namespaces directly is deprecated. Use class Config instead", DeprecationWarning) - config.namespaces = namespaces + if config is None: + logger.info('No OData version has been provided. Client defaulted to OData v2') + config = Config(ODataV2) - # create model instance from received metadata - logger.info('Creating OData Schema (version: %d)', odata_version) - schema = pyodata.v2.model.MetadataBuilder(metadata, config=config).build() + if namespaces is not None: + warnings.warn("Passing namespaces directly is deprecated. Use class Config instead", DeprecationWarning) + config.namespaces = namespaces - # create service instance based on model we have - logger.info('Creating OData Service (version: %d)', odata_version) - service = pyodata.v2.service.Service(url, schema, connection) + # create model instance from received metadata + logger.info('Creating OData Schema (version: %d)', config.odata_version) + schema = MetadataBuilder(metadata, config=config).build() - return service + # create service instance based on model we have + logger.info('Creating OData Service (version: %d)', config.odata_version) + service = Service(url, schema, connection) - raise PyODataException('No implementation for selected odata version {}'.format(odata_version)) + return service diff --git a/pyodata/config.py b/pyodata/config.py new file mode 100644 index 00000000..f6854aa1 --- /dev/null +++ b/pyodata/config.py @@ -0,0 +1,117 @@ +""" Contains definition of configuration class for PyOData and for ODATA versions. """ + +from abc import ABC, abstractmethod +from typing import Type, List, Dict, Callable + +from pyodata.policies import PolicyFatal, ParserError, ErrorPolicy + + +class ODATAVersion(ABC): + """ This is base class for different OData releases. In it we define what are supported types, elements and so on. + Furthermore, we specify how individual elements are parsed or represented by python objects. + """ + + def __init__(self): + raise RuntimeError('ODATAVersion and its children are intentionally stateless, ' + 'therefore you can not create instance of them') + + @staticmethod + @abstractmethod + def supported_primitive_types() -> List[str]: + """ Here we define which primitive types are supported and what is their python representation""" + + @staticmethod + @abstractmethod + def from_etree_callbacks() -> Dict[object, Callable]: + """ Here we define which elements are supported and what is their python representation""" + + @classmethod + def is_primitive_type_supported(cls, type_name): + """ Convenience method which decides whatever given type is supported.""" + return type_name in cls.supported_primitive_types() + + +class Config: + # pylint: disable=too-many-instance-attributes,missing-docstring + # All attributes have purpose and are used for configuration + # Having docstring for properties is not necessary as we do have type hints + + """ This is configuration class for PyOData. All session dependent settings should be stored here. """ + + def __init__(self, + odata_version: Type[ODATAVersion], + custom_error_policies=None, + default_error_policy=None, + xml_namespaces=None + ): + + """ + :param custom_error_policies: {ParserError: ErrorPolicy} (default None) + Used to specified individual policies for XML tags. See documentation for more + details. + + :param default_error_policy: ErrorPolicy (default PolicyFatal) + If custom policy is not specified for the tag, the default policy will be used. + + :param xml_namespaces: {str: str} (default None) + """ + + self._custom_error_policy = custom_error_policies + + if default_error_policy is None: + default_error_policy = PolicyFatal() + + self._default_error_policy = default_error_policy + + if xml_namespaces is None: + xml_namespaces = {} + + self._namespaces = xml_namespaces + + self._odata_version = odata_version + + self._sap_value_helper_directions = None + self._sap_annotation_value_list = None + self._annotation_namespaces = None + + def err_policy(self, error: ParserError) -> ErrorPolicy: + """ Returns error policy for given error. If custom error policy fo error is set, then returns that.""" + if self._custom_error_policy is None: + return self._default_error_policy + + return self._custom_error_policy.get(error, self._default_error_policy) + + def set_default_error_policy(self, policy: ErrorPolicy): + """ Sets default error policy as well as resets custom error policies""" + self._custom_error_policy = None + self._default_error_policy = policy + + def set_custom_error_policy(self, policies: Dict[ParserError, Type[ErrorPolicy]]): + """ Sets custom error policy. It should be called only after setting default error policy, otherwise + it has no effect. See implementation of "set_default_error_policy" for more details. + """ + self._custom_error_policy = policies + + @property + def namespaces(self) -> str: + return self._namespaces + + @namespaces.setter + def namespaces(self, value: Dict[str, str]): + self._namespaces = value + + @property + def odata_version(self) -> Type[ODATAVersion]: + return self._odata_version + + @property + def sap_value_helper_directions(self): + return self._sap_value_helper_directions + + @property + def sap_annotation_value_list(self): + return self._sap_annotation_value_list + + @property + def annotation_namespace(self): + return self._annotation_namespaces diff --git a/pyodata/model/__init__.py b/pyodata/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyodata/model/builder.py b/pyodata/model/builder.py new file mode 100644 index 00000000..304a31c6 --- /dev/null +++ b/pyodata/model/builder.py @@ -0,0 +1,151 @@ +"""Metadata Builder Implementation""" + +import collections +import io +from lxml import etree + +from pyodata.config import Config +from pyodata.exceptions import PyODataParserError +from pyodata.model.elements import ValueHelperParameter, Schema +import pyodata.v2 as v2 + + +ANNOTATION_NAMESPACES = { + 'edm': 'http://docs.oasis-open.org/odata/ns/edm', + 'edmx': 'http://docs.oasis-open.org/odata/ns/edmx' +} + +SAP_VALUE_HELPER_DIRECTIONS = { + 'com.sap.vocabularies.Common.v1.ValueListParameterIn': ValueHelperParameter.Direction.In, + 'com.sap.vocabularies.Common.v1.ValueListParameterInOut': ValueHelperParameter.Direction.InOut, + 'com.sap.vocabularies.Common.v1.ValueListParameterOut': ValueHelperParameter.Direction.Out, + 'com.sap.vocabularies.Common.v1.ValueListParameterDisplayOnly': ValueHelperParameter.Direction.DisplayOnly, + 'com.sap.vocabularies.Common.v1.ValueListParameterFilterOnly': ValueHelperParameter.Direction.FilterOnly +} + + +SAP_ANNOTATION_VALUE_LIST = ['com.sap.vocabularies.Common.v1.ValueList'] + + +# pylint: disable=protected-access +class MetadataBuilder: + """Metadata builder""" + + EDMX_WHITELIST = [ + 'http://schemas.microsoft.com/ado/2007/06/edmx', + 'http://docs.oasis-open.org/odata/ns/edmx', + ] + + EDM_WHITELIST = [ + 'http://schemas.microsoft.com/ado/2006/04/edm', + 'http://schemas.microsoft.com/ado/2007/05/edm', + 'http://schemas.microsoft.com/ado/2008/09/edm', + 'http://schemas.microsoft.com/ado/2009/11/edm', + 'http://docs.oasis-open.org/odata/ns/edm' + ] + + def __init__(self, xml, config=None): + self._xml = xml + + if config is None: + config = Config(v2.ODataV2) + self._config = config + + # pylint: disable=missing-docstring + @property + def config(self) -> Config: + return self._config + + def build(self): + """ Build model from the XML metadata""" + + if isinstance(self._xml, str): + mdf = io.StringIO(self._xml) + elif isinstance(self._xml, bytes): + mdf = io.BytesIO(self._xml) + else: + raise TypeError('Expected bytes or str type on metadata_xml, got : {0}'.format(type(self._xml))) + + namespaces = self._config.namespaces + xml = etree.parse(mdf) + edmx = xml.getroot() + + try: + dataservices = next((child for child in edmx if etree.QName(child.tag).localname == 'DataServices')) + except StopIteration: + raise PyODataParserError('Metadata document is missing the element DataServices') + + try: + schema = next((child for child in dataservices if etree.QName(child.tag).localname == 'Schema')) + except StopIteration: + raise PyODataParserError('Metadata document is missing the element Schema') + + if 'edmx' not in self._config.namespaces: + namespace = etree.QName(edmx.tag).namespace + + if namespace not in self.EDMX_WHITELIST: + raise PyODataParserError(f'Unsupported Edmx namespace - {namespace}') + + namespaces['edmx'] = namespace + + if 'edm' not in self._config.namespaces: + namespace = etree.QName(schema.tag).namespace + + if namespace not in self.EDM_WHITELIST: + raise PyODataParserError(f'Unsupported Schema namespace - {namespace}') + + namespaces['edm'] = namespace + + self._config.namespaces = namespaces + + self._config._sap_value_helper_directions = SAP_VALUE_HELPER_DIRECTIONS + self._config._sap_annotation_value_list = SAP_ANNOTATION_VALUE_LIST + self._config._annotation_namespaces = ANNOTATION_NAMESPACES + + self.update_alias(self.get_aliases(xml, self._config), self._config) + + edm_schemas = xml.xpath('/edmx:Edmx/edmx:DataServices/edm:Schema', namespaces=self._config.namespaces) + return Schema.from_etree(edm_schemas, self._config) + + @staticmethod + def get_aliases(edmx, config: Config): + """Get all aliases""" + + aliases = collections.defaultdict(set) + edm_root = edmx.xpath('/edmx:Edmx', namespaces=config.namespaces) + if edm_root: + edm_ref_includes = edm_root[0].xpath('edmx:Reference/edmx:Include', namespaces=config.annotation_namespace) + for ref_incl in edm_ref_includes: + namespace = ref_incl.get('Namespace') + alias = ref_incl.get('Alias') + if namespace is not None and alias is not None: + aliases[namespace].add(alias) + + return aliases + + @staticmethod + def update_alias(aliases, config: Config): + """Update config with aliases""" + + namespace, suffix = config.sap_annotation_value_list[0].rsplit('.', 1) + config._sap_annotation_value_list.extend([alias + '.' + suffix for alias in aliases[namespace]]) + + helper_direction_keys = list(config.sap_value_helper_directions.keys()) + for direction_key in helper_direction_keys: + namespace, suffix = direction_key.rsplit('.', 1) + for alias in aliases[namespace]: + config._sap_value_helper_directions[alias + '.' + suffix] = \ + config.sap_value_helper_directions[direction_key] + + +def schema_from_xml(metadata_xml, namespaces=None): + """Parses XML data and returns Schema representing OData Metadata""" + + meta = MetadataBuilder( + metadata_xml, + config=Config( + v2.ODataV2, + xml_namespaces=namespaces, + )) + + return meta.build() diff --git a/pyodata/v2/model.py b/pyodata/model/elements.py similarity index 50% rename from pyodata/v2/model.py rename to pyodata/model/elements.py index 9832a14c..9d97e78c 100644 --- a/pyodata/v2/model.py +++ b/pyodata/model/elements.py @@ -1,33 +1,38 @@ -""" -Simple representation of Metadata of OData V2 - -Author: Jakub Filak -Date: 2017-08-21 -""" -# pylint: disable=missing-docstring,too-many-instance-attributes,too-many-arguments,protected-access,no-member,line-too-long,logging-format-interpolation,too-few-public-methods,too-many-lines, too-many-public-methods +# pylint: disable=too-many-lines, missing-docstring, too-many-arguments, too-many-instance-attributes import collections -import datetime -from enum import Enum, auto -import io import itertools import logging -import re -import warnings -from abc import ABC, abstractmethod +from enum import Enum -from lxml import etree +from pyodata.config import Config +from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError -from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError +from pyodata.model.type_traits import EdmBooleanTypTraits, EdmDateTimeTypTraits, EdmPrefixedTypTraits, \ + EdmIntTypTraits, EdmLongIntTypTraits, EdmStringTypTraits, TypTraits, EdmStructTypTraits, EnumTypTrait -LOGGER_NAME = 'pyodata.model' IdentifierInfo = collections.namedtuple('IdentifierInfo', 'namespace name') TypeInfo = collections.namedtuple('TypeInfo', 'namespace name is_collection') def modlog(): - return logging.getLogger(LOGGER_NAME) + return logging.getLogger("Elements") + + +class FromEtreeMixin: + @classmethod + def from_etree(cls, etree, config: Config, **kwargs): + callbacks = config.odata_version.from_etree_callbacks() + if cls in callbacks: + callback = callbacks[cls] + else: + raise PyODataParserError(f'{cls.__name__} is unsupported in {config.odata_version.__name__}') + + if kwargs: + return callback(etree, config, kwargs) + + return callback(etree, config) class NullAssociation: @@ -44,95 +49,8 @@ def __init__(self, name): self.name = name def __getattr__(self, item): - raise PyODataModelError(f'Cannot access this type. An error occurred during parsing ' - f'type stated in xml({self.name}) was not found, therefore it has been replaced with NullType.') - - -class ErrorPolicy(ABC): - @abstractmethod - def resolve(self, ekseption): - pass - - -class PolicyFatal(ErrorPolicy): - def resolve(self, ekseption): - raise ekseption - - -class PolicyWarning(ErrorPolicy): - def __init__(self): - logging.basicConfig(format='%(levelname)s: %(message)s') - self._logger = logging.getLogger() - - def resolve(self, ekseption): - self._logger.warning('[%s] %s', ekseption.__class__.__name__, str(ekseption)) - - -class PolicyIgnore(ErrorPolicy): - def resolve(self, ekseption): - pass - - -class ParserError(Enum): - PROPERTY = auto() - ANNOTATION = auto() - ASSOCIATION = auto() - - ENUM_TYPE = auto() - ENTITY_TYPE = auto() - COMPLEX_TYPE = auto() - - -class Config: - - def __init__(self, - custom_error_policies=None, - default_error_policy=None, - xml_namespaces=None): - - """ - :param custom_error_policies: {ParserError: ErrorPolicy} (default None) - Used to specified individual policies for XML tags. See documentation for more - details. - - :param default_error_policy: ErrorPolicy (default PolicyFatal) - If custom policy is not specified for the tag, the default policy will be used. - - :param xml_namespaces: {str: str} (default None) - """ - - self._custom_error_policy = custom_error_policies - - if default_error_policy is None: - default_error_policy = PolicyFatal() - - self._default_error_policy = default_error_policy - - if xml_namespaces is None: - xml_namespaces = {} - - self._namespaces = xml_namespaces - - def err_policy(self, error: ParserError): - if self._custom_error_policy is None: - return self._default_error_policy - - return self._custom_error_policy.get(error, self._default_error_policy) - - def set_default_error_policy(self, policy: ErrorPolicy): - self._custom_error_policy = None - self._default_error_policy = policy - - def set_custom_error_policy(self, policies: dict): - self._custom_error_policy = policies - - @property - def namespaces(self): - return self._namespaces - - @namespaces.setter - def namespaces(self, value: dict): - self._namespaces = value + raise PyODataModelError(f'Cannot access this type. An error occurred during parsing type stated in ' + f'xml({self.name}) was not found, therefore it has been replaced with NullType.') class Identifier: @@ -221,8 +139,7 @@ def register_type(typ): Types.Types[collection_name] = collection_typ @staticmethod - def from_name(name): - + def from_name(name, config: Config): # build types hierarchy on first use (lazy creation) if Types.Types is None: Types._build_types() @@ -235,6 +152,9 @@ def from_name(name): name = name[11:-1] # strip collection() decorator search_name = 'Collection({})'.format(name) + if not config.odata_version.is_primitive_type_supported(name): + raise KeyError('Requested primitive type is not supported in this version of ODATA') + # pylint: disable=unsubscriptable-object return Types.Types[search_name] @@ -254,285 +174,12 @@ def parse_type_name(type_name): return TypeInfo(identifier.namespace, identifier.name, is_collection) -class EdmStructTypeSerializer: - """Basic implementation of (de)serialization for Edm complex types - - All properties existing in related Edm type are taken - into account, others are ignored - - TODO: it can happen that inifinite recurision occurs for cases - when property types are referencich each other. We need some research - here to avoid such cases. - """ - - @staticmethod - def to_literal(edm_type, value): - - # pylint: disable=no-self-use - if not edm_type: - raise PyODataException('Cannot encode value {} without complex type information'.format(value)) - - result = {} - for type_prop in edm_type.proprties(): - if type_prop.name in value: - result[type_prop.name] = type_prop.typ.traits.to_literal(value[type_prop.name]) - - return result - - @staticmethod - def from_json(edm_type, value): - - # pylint: disable=no-self-use - if not edm_type: - raise PyODataException('Cannot decode value {} without complex type information'.format(value)) - - result = {} - for type_prop in edm_type.proprties(): - if type_prop.name in value: - result[type_prop.name] = type_prop.typ.traits.from_json(value[type_prop.name]) - - return result - - @staticmethod - def from_literal(edm_type, value): - - # pylint: disable=no-self-use - if not edm_type: - raise PyODataException('Cannot decode value {} without complex type information'.format(value)) - - result = {} - for type_prop in edm_type.proprties(): - if type_prop.name in value: - result[type_prop.name] = type_prop.typ.traits.from_literal(value[type_prop.name]) - - return result - - -class TypTraits: - """Encapsulated differences between types""" - - def __repr__(self): - return self.__class__.__name__ - - # pylint: disable=no-self-use - def to_literal(self, value): - return value - - # pylint: disable=no-self-use - def from_json(self, value): - return value - - def to_json(self, value): - return value - - def from_literal(self, value): - return value - - -class EdmPrefixedTypTraits(TypTraits): - """Is good for all types where values have form: prefix'value'""" - - def __init__(self, prefix): - super(EdmPrefixedTypTraits, self).__init__() - self._prefix = prefix - - def to_literal(self, value): - return '{}\'{}\''.format(self._prefix, value) - - def from_literal(self, value): - matches = re.match("^{}'(.*)'$".format(self._prefix), value) - if not matches: - raise PyODataModelError( - "Malformed value {0} for primitive Edm type. Expected format is {1}'value'".format(value, self._prefix)) - return matches.group(1) - - -class EdmDateTimeTypTraits(EdmPrefixedTypTraits): - """Emd.DateTime traits - - Represents date and time with values ranging from 12:00:00 midnight, - January 1, 1753 A.D. through 11:59:59 P.M, December 9999 A.D. - - Literal form: - datetime'yyyy-mm-ddThh:mm[:ss[.fffffff]]' - NOTE: Spaces are not allowed between datetime and quoted portion. - datetime is case-insensitive - - Example 1: datetime'2000-12-12T12:00' - JSON has following format: /Date(1516614510000)/ - https://blogs.sap.com/2017/01/05/date-and-time-in-sap-gateway-foundation/ - """ - - def __init__(self): - super(EdmDateTimeTypTraits, self).__init__('datetime') - - def to_literal(self, value): - """Convert python datetime representation to literal format - - None: this could be done also via formatting string: - value.strftime('%Y-%m-%dT%H:%M:%S.%f') - """ - - if not isinstance(value, datetime.datetime): - raise PyODataModelError( - 'Cannot convert value of type {} to literal. Datetime format is required.'.format(type(value))) - - # Sets timezone to none to avoid including timezone information in the literal form. - return super(EdmDateTimeTypTraits, self).to_literal(value.replace(tzinfo=None).isoformat()) - - def to_json(self, value): - if isinstance(value, str): - return value - - # Converts datetime into timestamp in milliseconds in UTC timezone as defined in ODATA specification - # https://www.odata.org/documentation/odata-version-2-0/json-format/ - return f'/Date({int(value.replace(tzinfo=datetime.timezone.utc).timestamp()) * 1000})/' - - def from_json(self, value): - - if value is None: - return None - - matches = re.match(r"^/Date\((.*)\)/$", value) - if not matches: - raise PyODataModelError( - "Malformed value {0} for primitive Edm type. Expected format is /Date(value)/".format(value)) - value = matches.group(1) - - try: - # https://stackoverflow.com/questions/36179914/timestamp-out-of-range-for-platform-localtime-gmtime-function - value = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(milliseconds=int(value)) - except ValueError: - raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) - - return value - - def from_literal(self, value): - - if value is None: - return None - - value = super(EdmDateTimeTypTraits, self).from_literal(value) - - try: - value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') - except ValueError: - try: - value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S') - except ValueError: - try: - value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M') - except ValueError: - raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) - - return value - - -class EdmStringTypTraits(TypTraits): - """Edm.String traits""" - - # pylint: disable=no-self-use - def to_literal(self, value): - return '\'%s\'' % (value) - - # pylint: disable=no-self-use - def from_json(self, value): - return value.strip('\'') - - def from_literal(self, value): - return value.strip('\'') - - -class EdmBooleanTypTraits(TypTraits): - """Edm.Boolean traits""" - - # pylint: disable=no-self-use - def to_literal(self, value): - return 'true' if value else 'false' - - # pylint: disable=no-self-use - def from_json(self, value): - return value - - def from_literal(self, value): - return value == 'true' - - -class EdmIntTypTraits(TypTraits): - """All Edm Integer traits""" - - # pylint: disable=no-self-use - def to_literal(self, value): - return '%d' % (value) - - # pylint: disable=no-self-use - def from_json(self, value): - return int(value) - - def from_literal(self, value): - return int(value) - - -class EdmLongIntTypTraits(TypTraits): - """All Edm Integer for big numbers traits""" - - # pylint: disable=no-self-use - def to_literal(self, value): - return '%dL' % (value) - - # pylint: disable=no-self-use - def from_json(self, value): - if value[-1] == 'L': - return int(value[:-1]) - - return int(value) - - def from_literal(self, value): - return self.from_json(value) - - -class EdmStructTypTraits(TypTraits): - """Edm structural types (EntityType, ComplexType) traits""" - - def __init__(self, edm_type=None): - super(EdmStructTypTraits, self).__init__() - self._edm_type = edm_type - - # pylint: disable=no-self-use - def to_literal(self, value): - return EdmStructTypeSerializer.to_literal(self._edm_type, value) - - # pylint: disable=no-self-use - def from_json(self, value): - return EdmStructTypeSerializer.from_json(self._edm_type, value) - - def from_literal(self, value): - return EdmStructTypeSerializer.from_json(self._edm_type, value) - - -class EnumTypTrait(TypTraits): - def __init__(self, enum_type): - self._enum_type = enum_type - - def to_literal(self, value): - return f'{value.parent.namespace}.{value}' - - def from_json(self, value): - return getattr(self._enum_type, value) - - def from_literal(self, value): - # remove namespaces - enum_value = value.split('.')[-1] - # remove enum type - name = enum_value.split("'")[1] - return getattr(self._enum_type, name) - - class Typ(Identifier): Types = None Kinds = Enum('Kinds', 'Primitive Complex') + # pylint: disable=line-too-long def __init__(self, name, null_value, traits=TypTraits(), kind=None): super(Typ, self).__init__(name) @@ -662,7 +309,7 @@ def _check_scale_value(self): .format(self._scale, self._precision)) -class Schema: +class Schema(FromEtreeMixin): class Declaration: def __init__(self, namespace): super(Schema.Declaration, self).__init__() @@ -816,7 +463,7 @@ def get_type(self, type_info): # first look for type in primitive types try: - return Types.from_name(search_name) + return Types.from_name(search_name, self.config) except KeyError: pass @@ -951,229 +598,8 @@ def check_role_property_names(self, role, entity_type_name, namespace): except KeyError: raise PyODataModelError('Property {} does not exist in {}'.format(proprty, entity_type.name)) - # pylint: disable=too-many-locals,too-many-branches,too-many-statements - @staticmethod - def from_etree(schema_nodes, config: Config): - schema = Schema(config) - - # Parse Schema nodes by parts to get over the problem of not-yet known - # entity types referenced by entity sets, function imports and - # annotations. - - # First, process EnumType, EntityType and ComplexType nodes. They have almost no dependencies on other elements. - for schema_node in schema_nodes: - namespace = schema_node.get('Namespace') - decl = Schema.Declaration(namespace) - schema._decls[namespace] = decl - - for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces): - try: - etype = EnumType.from_etree(enum_type, namespace, config) - except (PyODataParserError, AttributeError) as ex: - config.err_policy(ParserError.ENUM_TYPE).resolve(ex) - etype = NullType(enum_type.get('Name')) - decl.add_enum_type(etype) - - for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): - try: - ctype = ComplexType.from_etree(complex_type, config) - except (KeyError, AttributeError) as ex: - config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) - ctype = NullType(complex_type.get('Name')) - - decl.add_complex_type(ctype) - - for entity_type in schema_node.xpath('edm:EntityType', namespaces=config.namespaces): - try: - etype = EntityType.from_etree(entity_type, config) - except (KeyError, AttributeError) as ex: - config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) - etype = NullType(entity_type.get('Name')) - - decl.add_entity_type(etype) - - # resolve types of properties - for stype in itertools.chain(schema.entity_types, schema.complex_types): - if isinstance(stype, NullType): - continue - - if stype.kind == Typ.Kinds.Complex: - # skip collections (no need to assign any types since type of collection - # items is resolved separately - if stype.is_collection: - continue - - for prop in stype.proprties(): - try: - prop.typ = schema.get_type(prop.type_info) - except PyODataModelError as ex: - config.err_policy(ParserError.PROPERTY).resolve(ex) - prop.typ = NullType(prop.type_info.name) - - # pylint: disable=too-many-nested-blocks - # Then, process Associations nodes because they refer EntityTypes and - # they are referenced by AssociationSets. - for schema_node in schema_nodes: - namespace = schema_node.get('Namespace') - decl = schema._decls[namespace] - - for association in schema_node.xpath('edm:Association', namespaces=config.namespaces): - assoc = Association.from_etree(association, config) - try: - for end_role in assoc.end_roles: - try: - # search and assign entity type (it must exist) - if end_role.entity_type_info.namespace is None: - end_role.entity_type_info.namespace = namespace - - etype = schema.entity_type(end_role.entity_type_info.name, end_role.entity_type_info.namespace) - - end_role.entity_type = etype - except KeyError: - raise PyODataModelError( - f'EntityType {end_role.entity_type_info.name} does not exist in Schema ' - f'Namespace {end_role.entity_type_info.namespace}') - - if assoc.referential_constraint is not None: - role_names = [end_role.role for end_role in assoc.end_roles] - principal_role = assoc.referential_constraint.principal - - # Check if the role was defined in the current association - if principal_role.name not in role_names: - raise RuntimeError( - 'Role {} was not defined in association {}'.format(principal_role.name, assoc.name)) - - # Check if principal role properties exist - role_name = principal_role.name - entity_type_name = assoc.end_by_role(role_name).entity_type_name - schema.check_role_property_names(principal_role, entity_type_name, namespace) - - dependent_role = assoc.referential_constraint.dependent - - # Check if the role was defined in the current association - if dependent_role.name not in role_names: - raise RuntimeError( - 'Role {} was not defined in association {}'.format(dependent_role.name, assoc.name)) - - # Check if dependent role properties exist - role_name = dependent_role.name - entity_type_name = assoc.end_by_role(role_name).entity_type_name - schema.check_role_property_names(dependent_role, entity_type_name, namespace) - except (PyODataModelError, RuntimeError) as ex: - config.err_policy(ParserError.ASSOCIATION).resolve(ex) - decl.associations[assoc.name] = NullAssociation(assoc.name) - else: - decl.associations[assoc.name] = assoc - - # resolve navigation properties - for stype in schema.entity_types: - # skip null type - if isinstance(stype, NullType): - continue - - # skip collections - if stype.is_collection: - continue - - for nav_prop in stype.nav_proprties: - try: - assoc = schema.association(nav_prop.association_info.name, nav_prop.association_info.namespace) - nav_prop.association = assoc - except KeyError as ex: - config.err_policy(ParserError.ASSOCIATION).resolve(ex) - nav_prop.association = NullAssociation(nav_prop.association_info.name) - - # Then, process EntitySet, FunctionImport and AssociationSet nodes. - for schema_node in schema_nodes: - namespace = schema_node.get('Namespace') - decl = schema._decls[namespace] - - for entity_set in schema_node.xpath('edm:EntityContainer/edm:EntitySet', namespaces=config.namespaces): - eset = EntitySet.from_etree(entity_set) - eset.entity_type = schema.entity_type(eset.entity_type_info[1], namespace=eset.entity_type_info[0]) - decl.entity_sets[eset.name] = eset - - for function_import in schema_node.xpath('edm:EntityContainer/edm:FunctionImport', namespaces=config.namespaces): - efn = FunctionImport.from_etree(function_import, config) - - # complete type information for return type and parameters - if efn.return_type_info is not None: - efn.return_type = schema.get_type(efn.return_type_info) - for param in efn.parameters: - param.typ = schema.get_type(param.type_info) - decl.function_imports[efn.name] = efn - - for association_set in schema_node.xpath('edm:EntityContainer/edm:AssociationSet', namespaces=config.namespaces): - assoc_set = AssociationSet.from_etree(association_set, config) - try: - try: - assoc_set.association_type = schema.association(assoc_set.association_type_name, - assoc_set.association_type_namespace) - except KeyError: - raise PyODataModelError( - 'Association {} does not exist in namespace {}' - .format(assoc_set.association_type_name, assoc_set.association_type_namespace)) - - for end in assoc_set.end_roles: - # Check if an entity set exists in the current scheme - # and add a reference to the corresponding entity set - try: - entity_set = schema.entity_set(end.entity_set_name, namespace) - end.entity_set = entity_set - except KeyError: - raise PyODataModelError('EntitySet {} does not exist in Schema Namespace {}' - .format(end.entity_set_name, namespace)) - # Check if role is defined in Association - if assoc_set.association_type.end_by_role(end.role) is None: - raise PyODataModelError('Role {} is not defined in association {}' - .format(end.role, assoc_set.association_type_name)) - except (PyODataModelError, KeyError) as ex: - config.err_policy(ParserError.ASSOCIATION).resolve(ex) - decl.association_sets[assoc_set.name] = NullAssociation(assoc_set.name) - else: - decl.association_sets[assoc_set.name] = assoc_set - - # pylint: disable=too-many-nested-blocks - # Finally, process Annotation nodes when all Scheme nodes are completely processed. - for schema_node in schema_nodes: - for annotation_group in schema_node.xpath('edm:Annotations', namespaces=ANNOTATION_NAMESPACES): - for annotation in ExternalAnnontation.from_etree(annotation_group): - if not annotation.element_namespace != schema.namespaces: - modlog().warning('{0} not in the namespaces {1}'.format(annotation, ','.join(schema.namespaces))) - continue - - try: - if annotation.kind == Annotation.Kinds.ValueHelper: - try: - annotation.entity_set = schema.entity_set( - annotation.collection_path, namespace=annotation.element_namespace) - except KeyError: - raise RuntimeError(f'Entity Set {annotation.collection_path} ' - f'for {annotation} does not exist') - - try: - vh_type = schema.typ(annotation.proprty_entity_type_name, - namespace=annotation.element_namespace) - except KeyError: - raise RuntimeError(f'Target Type {annotation.proprty_entity_type_name} ' - f'of {annotation} does not exist') - - try: - target_proprty = vh_type.proprty(annotation.proprty_name) - except KeyError: - raise RuntimeError(f'Target Property {annotation.proprty_name} ' - f'of {vh_type} as defined in {annotation} does not exist') - - annotation.proprty = target_proprty - target_proprty.value_helper = annotation - except (RuntimeError, PyODataModelError) as ex: - config.err_policy(ParserError.ANNOTATION).resolve(ex) - - return schema - - -class StructType(Typ): +class StructType(FromEtreeMixin, Typ): def __init__(self, name, label, is_value_list): super(StructType, self).__init__(name, None, EdmStructTypTraits(self), Typ.Kinds.Complex) @@ -1196,32 +622,7 @@ def proprty(self, property_name): def proprties(self): return list(self._properties.values()) - @classmethod - def from_etree(cls, type_node, config: Config): - name = type_node.get('Name') - label = sap_attribute_get_string(type_node, 'label') - is_value_list = sap_attribute_get_bool(type_node, 'value-list', False) - - stype = cls(name, label, is_value_list) - - for proprty in type_node.xpath('edm:Property', namespaces=config.namespaces): - stp = StructTypeProperty.from_etree(proprty) - - if stp.name in stype._properties: - raise KeyError('{0} already has property {1}'.format(stype, stp.name)) - - stype._properties[stp.name] = stp - - # We have to update the property when - # all properites are loaded because - # there might be links between them. - for ctp in list(stype._properties.values()): - ctp.struct_type = stype - - return stype - # implementation of Typ interface - @property def is_collection(self): return False @@ -1266,7 +667,7 @@ def parent(self): return self._parent -class EnumType(Identifier): +class EnumType(FromEtreeMixin, Identifier): def __init__(self, name, is_flags, underlying_type, namespace): super(EnumType, self).__init__(name) self._member = list() @@ -1300,50 +701,6 @@ def __getitem__(self, item): return member - # pylint: disable=too-many-locals - @staticmethod - def from_etree(type_node, namespace, config: Config): - ename = type_node.get('Name') - is_flags = type_node.get('IsFlags') - - underlying_type = type_node.get('UnderlyingType') - - valid_types = { - 'Edm.Byte': [0, 2 ** 8 - 1], - 'Edm.Int16': [-2 ** 15, 2 ** 15 - 1], - 'Edm.Int32': [-2 ** 31, 2 ** 31 - 1], - 'Edm.Int64': [-2 ** 63, 2 ** 63 - 1], - 'Edm.SByte': [-2 ** 7, 2 ** 7 - 1] - } - - if underlying_type not in valid_types: - raise PyODataParserError( - f'Type {underlying_type} is not valid as underlying type for EnumType - must be one of {valid_types}') - - mtype = Types.from_name(underlying_type) - etype = EnumType(ename, is_flags, mtype, namespace) - - members = type_node.xpath('edm:Member', namespaces=config.namespaces) - - next_value = 0 - for member in members: - name = member.get('Name') - value = member.get('Value') - - if value is not None: - next_value = int(value) - - vtype = valid_types[underlying_type] - if not vtype[0] < next_value < vtype[1]: - raise PyODataParserError(f'Value {next_value} is out of range for type {underlying_type}') - - emember = EnumMember(etype, name, next_value) - etype._member.append(emember) - - next_value += 1 - - return etype - @property def is_flags(self): return self._is_flags @@ -1376,26 +733,8 @@ def nav_proprties(self): def nav_proprty(self, property_name): return self._nav_properties[property_name] - @classmethod - def from_etree(cls, type_node, config: Config): - - etype = super(EntityType, cls).from_etree(type_node, config) - - for proprty in type_node.xpath('edm:Key/edm:PropertyRef', namespaces=config.namespaces): - etype._key.append(etype.proprty(proprty.get('Name'))) - - for proprty in type_node.xpath('edm:NavigationProperty', namespaces=config.namespaces): - navp = NavigationTypeProperty.from_etree(proprty) - if navp.name in etype._nav_properties: - raise KeyError('{0} already has navigation property {1}'.format(etype, navp.name)) - - etype._nav_properties[navp.name] = navp - - return etype - - -class EntitySet(Identifier): +class EntitySet(FromEtreeMixin, Identifier): def __init__(self, name, entity_type_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, topable, req_filter, label): super(EntitySet, self).__init__(name) @@ -1471,28 +810,8 @@ def requires_filter(self): def label(self): return self._label - @staticmethod - def from_etree(entity_set_node): - name = entity_set_node.get('Name') - et_info = Types.parse_type_name(entity_set_node.get('EntityType')) - - # TODO: create a class SAP attributes - addressable = sap_attribute_get_bool(entity_set_node, 'addressable', True) - creatable = sap_attribute_get_bool(entity_set_node, 'creatable', True) - updatable = sap_attribute_get_bool(entity_set_node, 'updatable', True) - deletable = sap_attribute_get_bool(entity_set_node, 'deletable', True) - searchable = sap_attribute_get_bool(entity_set_node, 'searchable', False) - countable = sap_attribute_get_bool(entity_set_node, 'countable', True) - pageable = sap_attribute_get_bool(entity_set_node, 'pageable', True) - topable = sap_attribute_get_bool(entity_set_node, 'topable', pageable) - req_filter = sap_attribute_get_bool(entity_set_node, 'requires-filter', False) - label = sap_attribute_get_string(entity_set_node, 'label') - - return EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, - topable, req_filter, label) - - -class StructTypeProperty(VariableDeclaration): + +class StructTypeProperty(FromEtreeMixin, VariableDeclaration): """Property of structure types (Entity/Complex type) Type of the property can be: @@ -1618,32 +937,8 @@ def value_helper(self, value): self._value_helper = value - @staticmethod - def from_etree(entity_type_property_node): - - return StructTypeProperty( - entity_type_property_node.get('Name'), - Types.parse_type_name(entity_type_property_node.get('Type')), - entity_type_property_node.get('Nullable'), - entity_type_property_node.get('MaxLength'), - entity_type_property_node.get('Precision'), - entity_type_property_node.get('Scale'), - # TODO: create a class SAP attributes - sap_attribute_get_bool(entity_type_property_node, 'unicode', True), - sap_attribute_get_string(entity_type_property_node, 'label'), - sap_attribute_get_bool(entity_type_property_node, 'creatable', True), - sap_attribute_get_bool(entity_type_property_node, 'updatable', True), - sap_attribute_get_bool(entity_type_property_node, 'sortable', True), - sap_attribute_get_bool(entity_type_property_node, 'filterable', True), - sap_attribute_get_string(entity_type_property_node, 'filter-restriction'), - sap_attribute_get_bool(entity_type_property_node, 'required-in-filter', False), - sap_attribute_get_string(entity_type_property_node, 'text'), - sap_attribute_get_bool(entity_type_property_node, 'visible', True), - sap_attribute_get_string(entity_type_property_node, 'display-format'), - sap_attribute_get_string(entity_type_property_node, 'value-list'), ) - - -class NavigationTypeProperty(VariableDeclaration): + +class NavigationTypeProperty(FromEtreeMixin, VariableDeclaration): """Defines a navigation property, which provides a reference to the other end of an association Unlike properties defined with the Property element, navigation properties do not define the @@ -1698,14 +993,8 @@ def to_role(self): def typ(self): return self.to_role.entity_type - @staticmethod - def from_etree(node): - - return NavigationTypeProperty( - node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship'))) - -class EndRole: +class EndRole(FromEtreeMixin): MULTIPLICITY_ONE = '1' MULTIPLICITY_ZERO_OR_ONE = '0..1' MULTIPLICITY_ZERO_OR_MORE = '*' @@ -1750,14 +1039,6 @@ def multiplicity(self): def role(self): return self._role - @staticmethod - def from_etree(end_role_node): - entity_type_info = Types.parse_type_name(end_role_node.get('Type')) - multiplicity = end_role_node.get('Multiplicity') - role = end_role_node.get('Role') - - return EndRole(entity_type_info, multiplicity, role) - class ReferentialConstraintRole: def __init__(self, name, property_names): @@ -1781,7 +1062,7 @@ class DependentRole(ReferentialConstraintRole): pass -class ReferentialConstraint: +class ReferentialConstraint(FromEtreeMixin): def __init__(self, principal, dependent): self._principal = principal self._dependent = dependent @@ -1794,42 +1075,8 @@ def principal(self): def dependent(self): return self._dependent - @staticmethod - def from_etree(referential_constraint_node, config: Config): - principal = referential_constraint_node.xpath('edm:Principal', namespaces=config.namespaces) - if len(principal) != 1: - raise RuntimeError('Referential constraint must contain exactly one principal element') - - principal_name = principal[0].get('Role') - if principal_name is None: - raise RuntimeError('Principal role name was not specified') - - principal_refs = [] - for property_ref in principal[0].xpath('edm:PropertyRef', namespaces=config.namespaces): - principal_refs.append(property_ref.get('Name')) - if not principal_refs: - raise RuntimeError('In role {} should be at least one principal property defined'.format(principal_name)) - - dependent = referential_constraint_node.xpath('edm:Dependent', namespaces=config.namespaces) - if len(dependent) != 1: - raise RuntimeError('Referential constraint must contain exactly one dependent element') - - dependent_name = dependent[0].get('Role') - if dependent_name is None: - raise RuntimeError('Dependent role name was not specified') - - dependent_refs = [] - for property_ref in dependent[0].xpath('edm:PropertyRef', namespaces=config.namespaces): - dependent_refs.append(property_ref.get('Name')) - if len(principal_refs) != len(dependent_refs): - raise RuntimeError('Number of properties should be equal for the principal {} and the dependent {}' - .format(principal_name, dependent_name)) - return ReferentialConstraint( - PrincipalRole(principal_name, principal_refs), DependentRole(dependent_name, dependent_refs)) - - -class Association: +class Association(FromEtreeMixin): """Defines a relationship between two entity types. An association must specify the entity types that are involved in @@ -1866,35 +1113,8 @@ def end_by_role(self, end_role): def referential_constraint(self): return self._referential_constraint - @staticmethod - def from_etree(association_node, config: Config): - name = association_node.get('Name') - association = Association(name) - - for end in association_node.xpath('edm:End', namespaces=config.namespaces): - end_role = EndRole.from_etree(end) - if end_role.entity_type_info is None: - raise RuntimeError('End type is not specified in the association {}'.format(name)) - association._end_roles.append(end_role) - - if len(association._end_roles) != 2: - raise RuntimeError('Association {} does not have two end roles'.format(name)) - - refer = association_node.xpath('edm:ReferentialConstraint', namespaces=config.namespaces) - if len(refer) > 1: - raise RuntimeError('In association {} is defined more than one referential constraint'.format(name)) - - if not refer: - referential_constraint = None - else: - referential_constraint = ReferentialConstraint.from_etree(refer[0], config) - - association._referential_constraint = referential_constraint - return association - - -class AssociationSetEndRole: +class AssociationSetEndRole(FromEtreeMixin): def __init__(self, role, entity_set_name): self._role = role self._entity_set_name = entity_set_name @@ -1926,15 +1146,8 @@ def entity_set(self, value): self._entity_set = value - @staticmethod - def from_etree(end_node): - role = end_node.get('Role') - entity_set = end_node.get('EntitySet') - - return AssociationSetEndRole(role, entity_set) - -class AssociationSet: +class AssociationSet(FromEtreeMixin): def __init__(self, name, association_type_name, association_type_namespace, end_roles): self._name = name self._association_type_name = association_type_name @@ -1983,23 +1196,8 @@ def association_type(self, value): raise RuntimeError('Cannot replace {} of {} with {}'.format(self._association_type, self, value)) self._association_type = value - @staticmethod - def from_etree(association_set_node, config: Config): - end_roles = [] - name = association_set_node.get('Name') - association = Identifier.parse(association_set_node.get('Association')) - - end_roles_list = association_set_node.xpath('edm:End', namespaces=config.namespaces) - if len(end_roles) > 2: - raise PyODataModelError('Association {} cannot have more than 2 end roles'.format(name)) - - for end_role in end_roles_list: - end_roles.append(AssociationSetEndRole.from_etree(end_role)) - - return AssociationSet(name, association.name, association.namespace, end_roles) - -class Annotation: +class Annotation(FromEtreeMixin): Kinds = Enum('Kinds', 'ValueHelper') def __init__(self, kind, target, qualifier=None): @@ -2028,30 +1226,9 @@ def target(self): def kind(self): return self._kind - @staticmethod - def from_etree(target, annotation_node): - term = annotation_node.get('Term') - if term in SAP_ANNOTATION_VALUE_LIST: - return ValueHelper.from_etree(target, annotation_node) - - modlog().warning('Unsupported Annotation({0})'.format(term)) - return None - - -class ExternalAnnontation: - @staticmethod - def from_etree(annotations_node): - target = annotations_node.get('Target') - - if annotations_node.get('Qualifier'): - modlog().warning('Ignoring qualified Annotations of {}'.format(target)) - return - for annotation in annotations_node.xpath('edm:Annotation', namespaces=ANNOTATION_NAMESPACES): - annot = Annotation.from_etree(target, annotation) - if annot is None: - continue - yield annot +class ExternalAnnotation(FromEtreeMixin): + pass class ValueHelper(Annotation): @@ -2153,35 +1330,8 @@ def list_property_param(self, name): raise KeyError('{0} has no list property {1}'.format(self, name)) - @staticmethod - def from_etree(target, annotation_node): - label = None - collection_path = None - search_supported = False - params_node = None - for prop_value in annotation_node.xpath('edm:Record/edm:PropertyValue', namespaces=ANNOTATION_NAMESPACES): - rprop = prop_value.get('Property') - if rprop == 'Label': - label = prop_value.get('String') - elif rprop == 'CollectionPath': - collection_path = prop_value.get('String') - elif rprop == 'SearchSupported': - search_supported = prop_value.get('Bool') - elif rprop == 'Parameters': - params_node = prop_value - - value_helper = ValueHelper(target, collection_path, label, search_supported) - - if params_node is not None: - for prm in params_node.xpath('edm:Collection/edm:Record', namespaces=ANNOTATION_NAMESPACES): - param = ValueHelperParameter.from_etree(prm) - param.value_helper = value_helper - value_helper._parameters.append(param) - - return value_helper - - -class ValueHelperParameter: + +class ValueHelperParameter(FromEtreeMixin): Direction = Enum('Direction', 'In InOut Out DisplayOnly FilterOnly') def __init__(self, direction, local_property_name, list_property_name): @@ -2247,23 +1397,8 @@ def list_property(self, value): self._list_property = value - @staticmethod - def from_etree(value_help_parameter_node): - typ = value_help_parameter_node.get('Type') - direction = SAP_VALUE_HELPER_DIRECTIONS[typ] - local_prop_name = None - list_prop_name = None - for pval in value_help_parameter_node.xpath('edm:PropertyValue', namespaces=ANNOTATION_NAMESPACES): - pv_name = pval.get('Property') - if pv_name == 'LocalDataProperty': - local_prop_name = pval.get('PropertyPath') - elif pv_name == 'ValueListProperty': - list_prop_name = pval.get('String') - - return ValueHelperParameter(direction, local_prop_name, list_prop_name) - - -class FunctionImport(Identifier): + +class FunctionImport(FromEtreeMixin, Identifier): def __init__(self, name, return_type_info, entity_set, parameters, http_method='GET'): super(FunctionImport, self).__init__(name) @@ -2306,32 +1441,6 @@ def get_parameter(self, parameter): def http_method(self): return self._http_method - # pylint: disable=too-many-locals - @staticmethod - def from_etree(function_import_node, config: Config): - name = function_import_node.get('Name') - entity_set = function_import_node.get('EntitySet') - http_method = metadata_attribute_get(function_import_node, 'HttpMethod') - - rt_type = function_import_node.get('ReturnType') - rt_info = None if rt_type is None else Types.parse_type_name(rt_type) - print(name, rt_type, rt_info) - - parameters = dict() - for param in function_import_node.xpath('edm:Parameter', namespaces=config.namespaces): - param_name = param.get('Name') - param_type_info = Types.parse_type_name(param.get('Type')) - param_nullable = param.get('Nullable') - param_max_length = param.get('MaxLength') - param_precision = param.get('Precision') - param_scale = param.get('Scale') - param_mode = param.get('Mode') - - parameters[param_name] = FunctionImportParameter(param_name, param_type_info, param_nullable, - param_max_length, param_precision, param_scale, param_mode) - - return FunctionImport(name, rt_info, entity_set, parameters, http_method) - class FunctionImportParameter(VariableDeclaration): Modes = Enum('Modes', 'In Out InOut') @@ -2370,144 +1479,3 @@ def sap_attribute_get_bool(node, attr, default): return False raise TypeError('Not a bool attribute: {0} = {1}'.format(attr, value)) - - -ANNOTATION_NAMESPACES = { - 'edm': 'http://docs.oasis-open.org/odata/ns/edm', - 'edmx': 'http://docs.oasis-open.org/odata/ns/edmx' -} - -SAP_VALUE_HELPER_DIRECTIONS = { - 'com.sap.vocabularies.Common.v1.ValueListParameterIn': ValueHelperParameter.Direction.In, - 'com.sap.vocabularies.Common.v1.ValueListParameterInOut': ValueHelperParameter.Direction.InOut, - 'com.sap.vocabularies.Common.v1.ValueListParameterOut': ValueHelperParameter.Direction.Out, - 'com.sap.vocabularies.Common.v1.ValueListParameterDisplayOnly': ValueHelperParameter.Direction.DisplayOnly, - 'com.sap.vocabularies.Common.v1.ValueListParameterFilterOnly': ValueHelperParameter.Direction.FilterOnly -} - - -SAP_ANNOTATION_VALUE_LIST = ['com.sap.vocabularies.Common.v1.ValueList'] - - -class MetadataBuilder: - EDMX_WHITELIST = [ - 'http://schemas.microsoft.com/ado/2007/06/edmx', - 'http://docs.oasis-open.org/odata/ns/edmx', - ] - - EDM_WHITELIST = [ - 'http://schemas.microsoft.com/ado/2006/04/edm', - 'http://schemas.microsoft.com/ado/2007/05/edm', - 'http://schemas.microsoft.com/ado/2008/09/edm', - 'http://schemas.microsoft.com/ado/2009/11/edm', - 'http://docs.oasis-open.org/odata/ns/edm' - ] - - def __init__(self, xml, config=None): - self._xml = xml - - if config is None: - config = Config() - self._config = config - - @property - def config(self): - return self._config - - def build(self): - """ Build model from the XML metadata""" - - if isinstance(self._xml, str): - mdf = io.StringIO(self._xml) - elif isinstance(self._xml, bytes): - mdf = io.BytesIO(self._xml) - else: - raise TypeError('Expected bytes or str type on metadata_xml, got : {0}'.format(type(self._xml))) - - namespaces = self._config.namespaces - xml = etree.parse(mdf) - edmx = xml.getroot() - - try: - dataservices = next((child for child in edmx if etree.QName(child.tag).localname == 'DataServices')) - except StopIteration: - raise PyODataParserError('Metadata document is missing the element DataServices') - - try: - schema = next((child for child in dataservices if etree.QName(child.tag).localname == 'Schema')) - except StopIteration: - raise PyODataParserError('Metadata document is missing the element Schema') - - if 'edmx' not in self._config.namespaces: - namespace = etree.QName(edmx.tag).namespace - - if namespace not in self.EDMX_WHITELIST: - raise PyODataParserError(f'Unsupported Edmx namespace - {namespace}') - - namespaces['edmx'] = namespace - - if 'edm' not in self._config.namespaces: - namespace = etree.QName(schema.tag).namespace - - if namespace not in self.EDM_WHITELIST: - raise PyODataParserError(f'Unsupported Schema namespace - {namespace}') - - namespaces['edm'] = namespace - - self._config.namespaces = namespaces - - self.update_global_variables_with_alias(self.get_aliases(xml, self._config)) - - edm_schemas = xml.xpath('/edmx:Edmx/edmx:DataServices/edm:Schema', namespaces=self._config.namespaces) - schema = Schema.from_etree(edm_schemas, self._config) - return schema - - @staticmethod - def get_aliases(edmx, config: Config): - """Get all aliases""" - - aliases = collections.defaultdict(set) - edm_root = edmx.xpath('/edmx:Edmx', namespaces=config.namespaces) - if edm_root: - edm_ref_includes = edm_root[0].xpath('edmx:Reference/edmx:Include', namespaces=ANNOTATION_NAMESPACES) - for ref_incl in edm_ref_includes: - namespace = ref_incl.get('Namespace') - alias = ref_incl.get('Alias') - if namespace is not None and alias is not None: - aliases[namespace].add(alias) - - return aliases - - @staticmethod - def update_global_variables_with_alias(aliases): - """Update global variables with aliases""" - - global SAP_ANNOTATION_VALUE_LIST # pylint: disable=global-statement - namespace, suffix = SAP_ANNOTATION_VALUE_LIST[0].rsplit('.', 1) - SAP_ANNOTATION_VALUE_LIST.extend([alias + '.' + suffix for alias in aliases[namespace]]) - - global SAP_VALUE_HELPER_DIRECTIONS # pylint: disable=global-statement - helper_direction_keys = list(SAP_VALUE_HELPER_DIRECTIONS.keys()) - for direction_key in helper_direction_keys: - namespace, suffix = direction_key.rsplit('.', 1) - for alias in aliases[namespace]: - SAP_VALUE_HELPER_DIRECTIONS[alias + '.' + suffix] = SAP_VALUE_HELPER_DIRECTIONS[direction_key] - - -def schema_from_xml(metadata_xml, namespaces=None): - """Parses XML data and returns Schema representing OData Metadata""" - - meta = MetadataBuilder( - metadata_xml, - config=Config( - xml_namespaces=namespaces, - )) - - return meta.build() - - -class Edmx: - @staticmethod - def parse(metadata_xml, namespaces=None): - warnings.warn("Edmx class is deprecated in favor of MetadataBuilder", DeprecationWarning) - return schema_from_xml(metadata_xml, namespaces) diff --git a/pyodata/model/from_etree_callbacks.py b/pyodata/model/from_etree_callbacks.py new file mode 100644 index 00000000..b5c0ff68 --- /dev/null +++ b/pyodata/model/from_etree_callbacks.py @@ -0,0 +1,344 @@ +""" Reusable implementation of from_etree methods for the most of edm elements """ + +# pylint: disable=unused-argument, missing-docstring, invalid-name +import logging + +from pyodata.config import Config +from pyodata.exceptions import PyODataParserError, PyODataModelError +from pyodata.model.elements import sap_attribute_get_bool, sap_attribute_get_string, StructType, StructTypeProperty, \ + NavigationTypeProperty, Identifier, Types, EnumType, EnumMember, EntitySet, EndRole, ReferentialConstraint, \ + PrincipalRole, DependentRole, Association, AssociationSetEndRole, AssociationSet, \ + ValueHelper, ValueHelperParameter, FunctionImportParameter, \ + FunctionImport, metadata_attribute_get, EntityType, ComplexType, Annotation + + +def modlog(): + return logging.getLogger("callbacks") + + +def struct_type_property_from_etree(entity_type_property_node, config: Config): + return StructTypeProperty( + entity_type_property_node.get('Name'), + Types.parse_type_name(entity_type_property_node.get('Type')), + entity_type_property_node.get('Nullable'), + entity_type_property_node.get('MaxLength'), + entity_type_property_node.get('Precision'), + entity_type_property_node.get('Scale'), + # TODO: create a class SAP attributes + sap_attribute_get_bool(entity_type_property_node, 'unicode', True), + sap_attribute_get_string(entity_type_property_node, 'label'), + sap_attribute_get_bool(entity_type_property_node, 'creatable', True), + sap_attribute_get_bool(entity_type_property_node, 'updatable', True), + sap_attribute_get_bool(entity_type_property_node, 'sortable', True), + sap_attribute_get_bool(entity_type_property_node, 'filterable', True), + sap_attribute_get_string(entity_type_property_node, 'filter-restriction'), + sap_attribute_get_bool(entity_type_property_node, 'required-in-filter', False), + sap_attribute_get_string(entity_type_property_node, 'text'), + sap_attribute_get_bool(entity_type_property_node, 'visible', True), + sap_attribute_get_string(entity_type_property_node, 'display-format'), + sap_attribute_get_string(entity_type_property_node, 'value-list'), ) + + +# pylint: disable=protected-access +def struct_type_from_etree(type_node, config: Config, kwargs): + name = type_node.get('Name') + label = sap_attribute_get_string(type_node, 'label') + is_value_list = sap_attribute_get_bool(type_node, 'value-list', False) + + stype = kwargs['type'](name, label, is_value_list) + + for proprty in type_node.xpath('edm:Property', namespaces=config.namespaces): + stp = StructTypeProperty.from_etree(proprty, config) + + if stp.name in stype._properties: + raise KeyError('{0} already has property {1}'.format(stype, stp.name)) + + stype._properties[stp.name] = stp + + # We have to update the property when + # all properites are loaded because + # there might be links between them. + for ctp in list(stype._properties.values()): + ctp.struct_type = stype + + return stype + + +def navigation_type_property_from_etree(node, config: Config): + return NavigationTypeProperty( + node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship'))) + + +def complex_type_from_etree(etree, config: Config): + return StructType.from_etree(etree, config, type=ComplexType) + + +# pylint: disable=protected-access +def entity_type_from_etree(etree, config: Config): + etype = StructType.from_etree(etree, config, type=EntityType) + + for proprty in etree.xpath('edm:Key/edm:PropertyRef', namespaces=config.namespaces): + etype._key.append(etype.proprty(proprty.get('Name'))) + + for proprty in etree.xpath('edm:NavigationProperty', namespaces=config.namespaces): + navp = NavigationTypeProperty.from_etree(proprty, config) + + if navp.name in etype._nav_properties: + raise KeyError('{0} already has navigation property {1}'.format(etype, navp.name)) + + etype._nav_properties[navp.name] = navp + + return etype + + +# pylint: disable=protected-access, too-many-locals +def enum_type_from_etree(type_node, config: Config, kwargs): + ename = type_node.get('Name') + is_flags = type_node.get('IsFlags') + + namespace = kwargs['namespace'] + + underlying_type = type_node.get('UnderlyingType') + + valid_types = { + 'Edm.Byte': [0, 2 ** 8 - 1], + 'Edm.Int16': [-2 ** 15, 2 ** 15 - 1], + 'Edm.Int32': [-2 ** 31, 2 ** 31 - 1], + 'Edm.Int64': [-2 ** 63, 2 ** 63 - 1], + 'Edm.SByte': [-2 ** 7, 2 ** 7 - 1] + } + + if underlying_type not in valid_types: + raise PyODataParserError( + f'Type {underlying_type} is not valid as underlying type for EnumType - must be one of {valid_types}') + + mtype = Types.from_name(underlying_type, config) + etype = EnumType(ename, is_flags, mtype, namespace) + + members = type_node.xpath('edm:Member', namespaces=config.namespaces) + + next_value = 0 + for member in members: + name = member.get('Name') + value = member.get('Value') + + if value is not None: + next_value = int(value) + + vtype = valid_types[underlying_type] + if not vtype[0] < next_value < vtype[1]: + raise PyODataParserError(f'Value {next_value} is out of range for type {underlying_type}') + + emember = EnumMember(etype, name, next_value) + etype._member.append(emember) + + next_value += 1 + + return etype + + +def entity_set_from_etree(entity_set_node, config): + name = entity_set_node.get('Name') + et_info = Types.parse_type_name(entity_set_node.get('EntityType')) + + # TODO: create a class SAP attributes + addressable = sap_attribute_get_bool(entity_set_node, 'addressable', True) + creatable = sap_attribute_get_bool(entity_set_node, 'creatable', True) + updatable = sap_attribute_get_bool(entity_set_node, 'updatable', True) + deletable = sap_attribute_get_bool(entity_set_node, 'deletable', True) + searchable = sap_attribute_get_bool(entity_set_node, 'searchable', False) + countable = sap_attribute_get_bool(entity_set_node, 'countable', True) + pageable = sap_attribute_get_bool(entity_set_node, 'pageable', True) + topable = sap_attribute_get_bool(entity_set_node, 'topable', pageable) + req_filter = sap_attribute_get_bool(entity_set_node, 'requires-filter', False) + label = sap_attribute_get_string(entity_set_node, 'label') + + return EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, + topable, req_filter, label) + + +def end_role_from_etree(end_role_node, config): + entity_type_info = Types.parse_type_name(end_role_node.get('Type')) + multiplicity = end_role_node.get('Multiplicity') + role = end_role_node.get('Role') + + return EndRole(entity_type_info, multiplicity, role) + + +def referential_constraint_from_etree(referential_constraint_node, config: Config): + principal = referential_constraint_node.xpath('edm:Principal', namespaces=config.namespaces) + if len(principal) != 1: + raise RuntimeError('Referential constraint must contain exactly one principal element') + + principal_name = principal[0].get('Role') + if principal_name is None: + raise RuntimeError('Principal role name was not specified') + + principal_refs = [] + for property_ref in principal[0].xpath('edm:PropertyRef', namespaces=config.namespaces): + principal_refs.append(property_ref.get('Name')) + if not principal_refs: + raise RuntimeError('In role {} should be at least one principal property defined'.format(principal_name)) + + dependent = referential_constraint_node.xpath('edm:Dependent', namespaces=config.namespaces) + if len(dependent) != 1: + raise RuntimeError('Referential constraint must contain exactly one dependent element') + + dependent_name = dependent[0].get('Role') + if dependent_name is None: + raise RuntimeError('Dependent role name was not specified') + + dependent_refs = [] + for property_ref in dependent[0].xpath('edm:PropertyRef', namespaces=config.namespaces): + dependent_refs.append(property_ref.get('Name')) + if len(principal_refs) != len(dependent_refs): + raise RuntimeError('Number of properties should be equal for the principal {} and the dependent {}' + .format(principal_name, dependent_name)) + + return ReferentialConstraint( + PrincipalRole(principal_name, principal_refs), DependentRole(dependent_name, dependent_refs)) + + +# pylint: disable=protected-access +def association_from_etree(association_node, config: Config): + name = association_node.get('Name') + association = Association(name) + + for end in association_node.xpath('edm:End', namespaces=config.namespaces): + end_role = EndRole.from_etree(end, config) + if end_role.entity_type_info is None: + raise RuntimeError('End type is not specified in the association {}'.format(name)) + association._end_roles.append(end_role) + + if len(association._end_roles) != 2: + raise RuntimeError('Association {} does not have two end roles'.format(name)) + + refer = association_node.xpath('edm:ReferentialConstraint', namespaces=config.namespaces) + if len(refer) > 1: + raise RuntimeError('In association {} is defined more than one referential constraint'.format(name)) + + if not refer: + referential_constraint = None + else: + referential_constraint = ReferentialConstraint.from_etree(refer[0], config) + + association._referential_constraint = referential_constraint + + return association + + +def association_set_end_role_from_etree(end_node, config): + role = end_node.get('Role') + entity_set = end_node.get('EntitySet') + + return AssociationSetEndRole(role, entity_set) + + +def association_set_from_etree(association_set_node, config: Config): + end_roles = [] + name = association_set_node.get('Name') + association = Identifier.parse(association_set_node.get('Association')) + + end_roles_list = association_set_node.xpath('edm:End', namespaces=config.namespaces) + if len(end_roles) > 2: + raise PyODataModelError('Association {} cannot have more than 2 end roles'.format(name)) + + for end_role in end_roles_list: + end_roles.append(AssociationSetEndRole.from_etree(end_role, config)) + + return AssociationSet(name, association.name, association.namespace, end_roles) + + +def external_annotation_from_etree(annotations_node, config): + target = annotations_node.get('Target') + + if annotations_node.get('Qualifier'): + modlog().warning('Ignoring qualified Annotations of %s', target) + return + + for annotation in annotations_node.xpath('edm:Annotation', namespaces=config.annotation_namespace): + annot = Annotation.from_etree(target, config, annotation_node=annotation) + if annot is None: + continue + yield annot + + +def annotation_from_etree(target, config, kwargs): + annotation_node = kwargs['annotation_node'] + term = annotation_node.get('Term') + + if term in config.sap_annotation_value_list: + return ValueHelper.from_etree(target, config, annotation_node=annotation_node) + + modlog().warning('Unsupported Annotation( %s )', term) + return None + + +def value_helper_from_etree(target, config, kwargs): + label = None + collection_path = None + search_supported = False + params_node = None + + annotation_node = kwargs['annotation_node'] + for prop_value in annotation_node.xpath('edm:Record/edm:PropertyValue', namespaces=config.annotation_namespace): + rprop = prop_value.get('Property') + if rprop == 'Label': + label = prop_value.get('String') + elif rprop == 'CollectionPath': + collection_path = prop_value.get('String') + elif rprop == 'SearchSupported': + search_supported = prop_value.get('Bool') + elif rprop == 'Parameters': + params_node = prop_value + + value_helper = ValueHelper(target, collection_path, label, search_supported) + + if params_node is not None: + for prm in params_node.xpath('edm:Collection/edm:Record', namespaces=config.annotation_namespace): + param = ValueHelperParameter.from_etree(prm, config) + param.value_helper = value_helper + value_helper._parameters.append(param) + + return value_helper + + +def value_helper_parameter_from_etree(value_help_parameter_node, config): + typ = value_help_parameter_node.get('Type') + direction = config.sap_value_helper_directions[typ] + local_prop_name = None + list_prop_name = None + for pval in value_help_parameter_node.xpath('edm:PropertyValue', namespaces=config.annotation_namespace): + pv_name = pval.get('Property') + if pv_name == 'LocalDataProperty': + local_prop_name = pval.get('PropertyPath') + elif pv_name == 'ValueListProperty': + list_prop_name = pval.get('String') + + return ValueHelperParameter(direction, local_prop_name, list_prop_name) + + +# pylint: disable=too-many-locals +def function_import_from_etree(function_import_node, config: Config): + name = function_import_node.get('Name') + entity_set = function_import_node.get('EntitySet') + http_method = metadata_attribute_get(function_import_node, 'HttpMethod') + + rt_type = function_import_node.get('ReturnType') + rt_info = None if rt_type is None else Types.parse_type_name(rt_type) + print(name, rt_type, rt_info) + + parameters = dict() + for param in function_import_node.xpath('edm:Parameter', namespaces=config.namespaces): + param_name = param.get('Name') + param_type_info = Types.parse_type_name(param.get('Type')) + param_nullable = param.get('Nullable') + param_max_length = param.get('MaxLength') + param_precision = param.get('Precision') + param_scale = param.get('Scale') + param_mode = param.get('Mode') + + parameters[param_name] = FunctionImportParameter(param_name, param_type_info, param_nullable, + param_max_length, param_precision, param_scale, param_mode) + + return FunctionImport(name, rt_info, entity_set, parameters, http_method) diff --git a/pyodata/model/type_traits.py b/pyodata/model/type_traits.py new file mode 100644 index 00000000..3bd59aea --- /dev/null +++ b/pyodata/model/type_traits.py @@ -0,0 +1,280 @@ +# pylint: disable=missing-docstring + +import datetime +import re + +from pyodata.exceptions import PyODataException, PyODataModelError + + +class EdmStructTypeSerializer: + """Basic implementation of (de)serialization for Edm complex types + + All properties existing in related Edm type are taken + into account, others are ignored + + TODO: it can happen that inifinite recurision occurs for cases + when property types are referencich each other. We need some research + here to avoid such cases. + """ + + @staticmethod + def to_literal(edm_type, value): + + # pylint: disable=no-self-use + if not edm_type: + raise PyODataException('Cannot encode value {} without complex type information'.format(value)) + + result = {} + for type_prop in edm_type.proprties(): + if type_prop.name in value: + result[type_prop.name] = type_prop.typ.traits.to_literal(value[type_prop.name]) + + return result + + @staticmethod + def from_json(edm_type, value): + + # pylint: disable=no-self-use + if not edm_type: + raise PyODataException('Cannot decode value {} without complex type information'.format(value)) + + result = {} + for type_prop in edm_type.proprties(): + if type_prop.name in value: + result[type_prop.name] = type_prop.typ.traits.from_json(value[type_prop.name]) + + return result + + @staticmethod + def from_literal(edm_type, value): + + # pylint: disable=no-self-use + if not edm_type: + raise PyODataException('Cannot decode value {} without complex type information'.format(value)) + + result = {} + for type_prop in edm_type.proprties(): + if type_prop.name in value: + result[type_prop.name] = type_prop.typ.traits.from_literal(value[type_prop.name]) + + return result + + +class TypTraits: + """Encapsulated differences between types""" + + def __repr__(self): + return self.__class__.__name__ + + # pylint: disable=no-self-use + def to_literal(self, value): + return value + + # pylint: disable=no-self-use + def from_json(self, value): + return value + + def to_json(self, value): + return value + + def from_literal(self, value): + return value + + +class EdmPrefixedTypTraits(TypTraits): + """Is good for all types where values have form: prefix'value'""" + + def __init__(self, prefix): + super(EdmPrefixedTypTraits, self).__init__() + self._prefix = prefix + + def to_literal(self, value): + return '{}\'{}\''.format(self._prefix, value) + + def from_literal(self, value): + matches = re.match("^{}'(.*)'$".format(self._prefix), value) + if not matches: + raise PyODataModelError( + "Malformed value {0} for primitive Edm type. Expected format is {1}'value'".format(value, self._prefix)) + return matches.group(1) + + +class EdmDateTimeTypTraits(EdmPrefixedTypTraits): + """Emd.DateTime traits + + Represents date and time with values ranging from 12:00:00 midnight, + January 1, 1753 A.D. through 11:59:59 P.M, December 9999 A.D. + + Literal form: + datetime'yyyy-mm-ddThh:mm[:ss[.fffffff]]' + NOTE: Spaces are not allowed between datetime and quoted portion. + datetime is case-insensitive + + Example 1: datetime'2000-12-12T12:00' + JSON has following format: /Date(1516614510000)/ + https://blogs.sap.com/2017/01/05/date-and-time-in-sap-gateway-foundation/ + """ + + def __init__(self): + super(EdmDateTimeTypTraits, self).__init__('datetime') + + def to_literal(self, value): + """Convert python datetime representation to literal format + + None: this could be done also via formatting string: + value.strftime('%Y-%m-%dT%H:%M:%S.%f') + """ + + if not isinstance(value, datetime.datetime): + raise PyODataModelError( + 'Cannot convert value of type {} to literal. Datetime format is required.'.format(type(value))) + + return super(EdmDateTimeTypTraits, self).to_literal(value.replace(tzinfo=None).isoformat()) + + def to_json(self, value): + if isinstance(value, str): + return value + + # Converts datetime into timestamp in milliseconds in UTC timezone as defined in ODATA specification + # https://www.odata.org/documentation/odata-version-2-0/json-format/ + return f'/Date({int(value.replace(tzinfo=datetime.timezone.utc).timestamp()) * 1000})/' + + def from_json(self, value): + + if value is None: + return None + + matches = re.match(r"^/Date\((.*)\)/$", value) + if not matches: + raise PyODataModelError( + "Malformed value {0} for primitive Edm type. Expected format is /Date(value)/".format(value)) + value = matches.group(1) + + try: + # https://stackoverflow.com/questions/36179914/timestamp-out-of-range-for-platform-localtime-gmtime-function + value = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta( + milliseconds=int(value)) + except ValueError: + raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) + + return value + + def from_literal(self, value): + + if value is None: + return None + + value = super(EdmDateTimeTypTraits, self).from_literal(value) + + try: + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') + except ValueError: + try: + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S') + except ValueError: + try: + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M') + except ValueError: + raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) + + return value + + +class EdmStringTypTraits(TypTraits): + """Edm.String traits""" + + # pylint: disable=no-self-use + def to_literal(self, value): + return '\'%s\'' % (value) + + # pylint: disable=no-self-use + def from_json(self, value): + return value.strip('\'') + + def from_literal(self, value): + return value.strip('\'') + + +class EdmBooleanTypTraits(TypTraits): + """Edm.Boolean traits""" + + # pylint: disable=no-self-use + def to_literal(self, value): + return 'true' if value else 'false' + + # pylint: disable=no-self-use + def from_json(self, value): + return value + + def from_literal(self, value): + return value == 'true' + + +class EdmIntTypTraits(TypTraits): + """All Edm Integer traits""" + + # pylint: disable=no-self-use + def to_literal(self, value): + return '%d' % (value) + + # pylint: disable=no-self-use + def from_json(self, value): + return int(value) + + def from_literal(self, value): + return int(value) + + +class EdmLongIntTypTraits(TypTraits): + """All Edm Integer for big numbers traits""" + + # pylint: disable=no-self-use + def to_literal(self, value): + return '%dL' % (value) + + # pylint: disable=no-self-use + def from_json(self, value): + if value[-1] == 'L': + return int(value[:-1]) + + return int(value) + + def from_literal(self, value): + return self.from_json(value) + + +class EdmStructTypTraits(TypTraits): + """Edm structural types (EntityType, ComplexType) traits""" + + def __init__(self, edm_type=None): + super(EdmStructTypTraits, self).__init__() + self._edm_type = edm_type + + # pylint: disable=no-self-use + def to_literal(self, value): + return EdmStructTypeSerializer.to_literal(self._edm_type, value) + + # pylint: disable=no-self-use + def from_json(self, value): + return EdmStructTypeSerializer.from_json(self._edm_type, value) + + def from_literal(self, value): + return EdmStructTypeSerializer.from_json(self._edm_type, value) + + +class EnumTypTrait(TypTraits): + def __init__(self, enum_type): + self._enum_type = enum_type + + def to_literal(self, value): + return f'{value.parent.namespace}.{value}' + + def from_json(self, value): + return getattr(self._enum_type, value) + + def from_literal(self, value): + # remove namespaces + enum_value = value.split('.')[-1] + # remove enum type + name = enum_value.split("'")[1] + return getattr(self._enum_type, name) diff --git a/pyodata/policies.py b/pyodata/policies.py new file mode 100644 index 00000000..0d95339c --- /dev/null +++ b/pyodata/policies.py @@ -0,0 +1,48 @@ +""" + This module servers as repository of different kind of errors which can be encounter during parsing and + policies which defines how the parser should response to given error. +""" + +import logging +from abc import ABC, abstractmethod +from enum import Enum, auto + + +class ParserError(Enum): + """ Represents all the different errors the parser is able to deal with.""" + PROPERTY = auto() + ANNOTATION = auto() + ASSOCIATION = auto() + + ENUM_TYPE = auto() + ENTITY_TYPE = auto() + COMPLEX_TYPE = auto() + + +class ErrorPolicy(ABC): + """ All policies has to inhere this class""" + @abstractmethod + def resolve(self, ekseption): + """ This method is invoked when an error arise.""" + + +class PolicyFatal(ErrorPolicy): + """ Encounter error should result in parser failing. """ + def resolve(self, ekseption): + raise ekseption + + +class PolicyWarning(ErrorPolicy): + """ Encounter error is logged, but parser continues as nothing has happened """ + def __init__(self): + logging.basicConfig(format='%(levelname)s: %(message)s') + self._logger = logging.getLogger() + + def resolve(self, ekseption): + self._logger.warning('[%s] %s', ekseption.__class__.__name__, str(ekseption)) + + +class PolicyIgnore(ErrorPolicy): + """ Encounter error is ignored and parser continues as nothing has happened """ + def resolve(self, ekseption): + pass diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index e69de29b..d6b5f2b3 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -0,0 +1,296 @@ +""" This module represents implementation of ODATA V2 """ + +import itertools +import logging +from typing import List + +from pyodata.policies import ParserError +from pyodata.config import ODATAVersion, Config +from pyodata.exceptions import PyODataParserError, PyODataModelError + +from pyodata.model.elements import StructTypeProperty, StructType, NavigationTypeProperty, ComplexType, EntityType, \ + EnumType, EntitySet, EndRole, ReferentialConstraint, Association, AssociationSetEndRole, AssociationSet, \ + ExternalAnnotation, Annotation, ValueHelper, ValueHelperParameter, FunctionImport, Schema, NullType, Typ, \ + NullAssociation + +from pyodata.model.from_etree_callbacks import struct_type_property_from_etree, struct_type_from_etree, \ + navigation_type_property_from_etree, complex_type_from_etree, entity_type_from_etree, enum_type_from_etree, \ + entity_set_from_etree, end_role_from_etree, referential_constraint_from_etree, association_from_etree, \ + association_set_end_role_from_etree, association_set_from_etree, external_annotation_from_etree, \ + annotation_from_etree, value_helper_from_etree, value_helper_parameter_from_etree, function_import_from_etree + + +def modlog(): + """ Logging function for debugging.""" + return logging.getLogger("v2") + + +class ODataV2(ODATAVersion): + """ Definition of OData V2 """ + + @staticmethod + def from_etree_callbacks(): + return { + StructTypeProperty: struct_type_property_from_etree, + StructType: struct_type_from_etree, + NavigationTypeProperty: navigation_type_property_from_etree, + ComplexType: complex_type_from_etree, + EntityType: entity_type_from_etree, + EnumType: enum_type_from_etree, + EntitySet: entity_set_from_etree, + EndRole: end_role_from_etree, + ReferentialConstraint: referential_constraint_from_etree, + Association: association_from_etree, + AssociationSetEndRole: association_set_end_role_from_etree, + AssociationSet: association_set_from_etree, + ExternalAnnotation: external_annotation_from_etree, + Annotation: annotation_from_etree, + ValueHelper: value_helper_from_etree, + ValueHelperParameter: value_helper_parameter_from_etree, + FunctionImport: function_import_from_etree, + Schema: ODataV2.schema_from_etree + } + + @staticmethod + def supported_primitive_types() -> List[str]: + return [ + 'Null', + 'Edm.Binary', + 'Edm.Boolean', + 'Edm.Byte', + 'Edm.DateTime', + 'Edm.Decimal', + 'Edm.Double', + 'Edm.Single', + 'Edm.Guid', + 'Edm.Int16', + 'Edm.Int32', + 'Edm.Int64', + 'Edm.SByte', + 'Edm.String', + 'Edm.Time', + 'Edm.DateTimeOffset', + ] + + # pylint: disable=too-many-locals,too-many-branches,too-many-statements, protected-access,missing-docstring + @staticmethod + def schema_from_etree(schema_nodes, config: Config): + schema = Schema(config) + + # Parse Schema nodes by parts to get over the problem of not-yet known + # entity types referenced by entity sets, function imports and + # annotations. + + # First, process EnumType, EntityType and ComplexType nodes. They have almost no dependencies on other elements. + for schema_node in schema_nodes: + namespace = schema_node.get('Namespace') + decl = Schema.Declaration(namespace) + schema._decls[namespace] = decl + + for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces): + try: + etype = EnumType.from_etree(enum_type, config, namespace=namespace) + except (PyODataParserError, AttributeError) as ex: + config.err_policy(ParserError.ENUM_TYPE).resolve(ex) + etype = NullType(enum_type.get('Name')) + + decl.add_enum_type(etype) + + for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): + try: + ctype = ComplexType.from_etree(complex_type, config) + except (KeyError, AttributeError) as ex: + config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) + ctype = NullType(complex_type.get('Name')) + + decl.add_complex_type(ctype) + + for entity_type in schema_node.xpath('edm:EntityType', namespaces=config.namespaces): + try: + etype = EntityType.from_etree(entity_type, config) + except (KeyError, AttributeError) as ex: + config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) + etype = NullType(entity_type.get('Name')) + + decl.add_entity_type(etype) + + # resolve types of properties + for stype in itertools.chain(schema.entity_types, schema.complex_types): + if isinstance(stype, NullType): + continue + + if stype.kind == Typ.Kinds.Complex: + # skip collections (no need to assign any types since type of collection + # items is resolved separately + if stype.is_collection: + continue + + for prop in stype.proprties(): + try: + prop.typ = schema.get_type(prop.type_info) + except PyODataModelError as ex: + config.err_policy(ParserError.PROPERTY).resolve(ex) + prop.typ = NullType(prop.type_info.name) + + # pylint: disable=too-many-nested-blocks + # Then, process Associations nodes because they refer EntityTypes and + # they are referenced by AssociationSets. + for schema_node in schema_nodes: + namespace = schema_node.get('Namespace') + decl = schema._decls[namespace] + + for association in schema_node.xpath('edm:Association', namespaces=config.namespaces): + assoc = Association.from_etree(association, config) + try: + for end_role in assoc.end_roles: + try: + # search and assign entity type (it must exist) + if end_role.entity_type_info.namespace is None: + end_role.entity_type_info.namespace = namespace + + etype = schema.entity_type(end_role.entity_type_info.name, + end_role.entity_type_info.namespace) + + end_role.entity_type = etype + except KeyError: + raise PyODataModelError( + f'EntityType {end_role.entity_type_info.name} does not exist in Schema ' + f'Namespace {end_role.entity_type_info.namespace}') + + if assoc.referential_constraint is not None: + role_names = [end_role.role for end_role in assoc.end_roles] + principal_role = assoc.referential_constraint.principal + + # Check if the role was defined in the current association + if principal_role.name not in role_names: + raise RuntimeError( + 'Role {} was not defined in association {}'.format(principal_role.name, assoc.name)) + + # Check if principal role properties exist + role_name = principal_role.name + entity_type_name = assoc.end_by_role(role_name).entity_type_name + schema.check_role_property_names(principal_role, entity_type_name, namespace) + + dependent_role = assoc.referential_constraint.dependent + + # Check if the role was defined in the current association + if dependent_role.name not in role_names: + raise RuntimeError( + 'Role {} was not defined in association {}'.format(dependent_role.name, assoc.name)) + + # Check if dependent role properties exist + role_name = dependent_role.name + entity_type_name = assoc.end_by_role(role_name).entity_type_name + schema.check_role_property_names(dependent_role, entity_type_name, namespace) + except (PyODataModelError, RuntimeError) as ex: + config.err_policy(ParserError.ASSOCIATION).resolve(ex) + decl.associations[assoc.name] = NullAssociation(assoc.name) + else: + decl.associations[assoc.name] = assoc + + # resolve navigation properties + for stype in schema.entity_types: + # skip null type + if isinstance(stype, NullType): + continue + + # skip collections + if stype.is_collection: + continue + + for nav_prop in stype.nav_proprties: + try: + assoc = schema.association(nav_prop.association_info.name, nav_prop.association_info.namespace) + nav_prop.association = assoc + except KeyError as ex: + config.err_policy(ParserError.ASSOCIATION).resolve(ex) + nav_prop.association = NullAssociation(nav_prop.association_info.name) + + # Then, process EntitySet, FunctionImport and AssociationSet nodes. + for schema_node in schema_nodes: + namespace = schema_node.get('Namespace') + decl = schema._decls[namespace] + + for entity_set in schema_node.xpath('edm:EntityContainer/edm:EntitySet', namespaces=config.namespaces): + eset = EntitySet.from_etree(entity_set, config) + eset.entity_type = schema.entity_type(eset.entity_type_info[1], namespace=eset.entity_type_info[0]) + decl.entity_sets[eset.name] = eset + + for function_import in schema_node.xpath('edm:EntityContainer/edm:FunctionImport', + namespaces=config.namespaces): + efn = FunctionImport.from_etree(function_import, config) + + # complete type information for return type and parameters + if efn.return_type_info is not None: + efn.return_type = schema.get_type(efn.return_type_info) + for param in efn.parameters: + param.typ = schema.get_type(param.type_info) + decl.function_imports[efn.name] = efn + + for association_set in schema_node.xpath('edm:EntityContainer/edm:AssociationSet', + namespaces=config.namespaces): + assoc_set = AssociationSet.from_etree(association_set, config) + try: + try: + assoc_set.association_type = schema.association(assoc_set.association_type_name, + assoc_set.association_type_namespace) + except KeyError: + raise PyODataModelError( + 'Association {} does not exist in namespace {}' + .format(assoc_set.association_type_name, assoc_set.association_type_namespace)) + + for end in assoc_set.end_roles: + # Check if an entity set exists in the current scheme + # and add a reference to the corresponding entity set + try: + entity_set = schema.entity_set(end.entity_set_name, namespace) + end.entity_set = entity_set + except KeyError: + raise PyODataModelError('EntitySet {} does not exist in Schema Namespace {}' + .format(end.entity_set_name, namespace)) + # Check if role is defined in Association + if assoc_set.association_type.end_by_role(end.role) is None: + raise PyODataModelError('Role {} is not defined in association {}' + .format(end.role, assoc_set.association_type_name)) + except (PyODataModelError, KeyError) as ex: + config.err_policy(ParserError.ASSOCIATION).resolve(ex) + decl.association_sets[assoc_set.name] = NullAssociation(assoc_set.name) + else: + decl.association_sets[assoc_set.name] = assoc_set + + # pylint: disable=too-many-nested-blocks + # Finally, process Annotation nodes when all Scheme nodes are completely processed. + for schema_node in schema_nodes: + for annotation_group in schema_node.xpath('edm:Annotations', namespaces=config.annotation_namespace): + etree = ExternalAnnotation.from_etree(annotation_group, config) + for annotation in etree: + if not annotation.element_namespace != schema.namespaces: + modlog().warning('%s not in the namespaces %s', annotation, ','.join(schema.namespaces)) + continue + + try: + if annotation.kind == Annotation.Kinds.ValueHelper: + try: + annotation.entity_set = schema.entity_set( + annotation.collection_path, namespace=annotation.element_namespace) + except KeyError: + raise RuntimeError(f'Entity Set {annotation.collection_path} ' + f'for {annotation} does not exist') + + try: + vh_type = schema.typ(annotation.proprty_entity_type_name, + namespace=annotation.element_namespace) + except KeyError: + raise RuntimeError(f'Target Type {annotation.proprty_entity_type_name} ' + f'of {annotation} does not exist') + + try: + target_proprty = vh_type.proprty(annotation.proprty_name) + except KeyError: + raise RuntimeError(f'Target Property {annotation.proprty_name} ' + f'of {vh_type} as defined in {annotation} does not exist') + annotation.proprty = target_proprty + target_proprty.value_helper = annotation + except (RuntimeError, PyODataModelError) as ex: + config.err_policy(ParserError.ANNOTATION).resolve(ex) + return schema diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index 95a27dcf..23b53f6c 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -16,8 +16,8 @@ import requests +from pyodata.model import elements from pyodata.exceptions import HttpError, PyODataException, ExpressionError -from . import model LOGGER_NAME = 'pyodata.service' @@ -736,8 +736,8 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key= # cache value according to multiplicity if prop.to_role.multiplicity in \ - [model.EndRole.MULTIPLICITY_ONE, - model.EndRole.MULTIPLICITY_ZERO_OR_ONE]: + [elements.EndRole.MULTIPLICITY_ONE, + elements.EndRole.MULTIPLICITY_ZERO_OR_ONE]: # cache None in case we receive nothing (null) instead of entity data if proprties[prop.name] is None: @@ -745,7 +745,7 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key= else: self._cache[prop.name] = EntityProxy(service, None, prop_etype, proprties[prop.name]) - elif prop.to_role.multiplicity == model.EndRole.MULTIPLICITY_ZERO_OR_MORE: + elif prop.to_role.multiplicity == elements.EndRole.MULTIPLICITY_ZERO_OR_MORE: # default value is empty array self._cache[prop.name] = [] @@ -815,7 +815,7 @@ def nav(self, nav_property): raise PyODataException('No association set for role {}'.format(navigation_property.to_role)) roles = navigation_property.association.end_roles - if all((role.multiplicity != model.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): + if all((role.multiplicity != elements.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): return NavEntityProxy(self, nav_property, navigation_entity_set.entity_type, {}) return EntitySetProxy( @@ -1024,7 +1024,7 @@ def nav(self, nav_property, key): 'No association set for role {} {}'.format(navigation_property.to_role, association_set.end_roles)) roles = navigation_property.association.end_roles - if all((role.multiplicity != model.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): + if all((role.multiplicity != elements.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): return self._get_nav_entity(key, nav_property, navigation_entity_set) return EntitySetProxy( @@ -1260,7 +1260,7 @@ def function_import_handler(fimport, response): response_data = response.json()['d'] # 1. if return types is "entity type", return instance of appropriate entity proxy - if isinstance(fimport.return_type, model.EntityType): + if isinstance(fimport.return_type, elements.EntityType): entity_set = self._service.schema.entity_set(fimport.entity_set_name) return EntityProxy(self._service, entity_set, fimport.return_type, response_data) diff --git a/tests/conftest.py b/tests/conftest.py index ddd6f723..a5148a19 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import logging import os import pytest -from pyodata.v2.model import schema_from_xml +from pyodata.model.builder import schema_from_xml @pytest.fixture diff --git a/tests/test_client.py b/tests/test_client.py index 4dcbdbbb..374c573a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,25 +1,20 @@ """PyOData Client tests""" +from unittest.mock import patch import responses import requests import pytest -import pyodata -import pyodata.v2.service -from unittest.mock import patch -from pyodata.exceptions import PyODataException, HttpError -from pyodata.v2.model import ParserError, PolicyWarning, PolicyFatal, PolicyIgnore, Config -SERVICE_URL = 'http://example.com' +import pyodata +from pyodata.exceptions import PyODataException, HttpError +from pyodata.policies import ParserError, PolicyWarning, PolicyFatal, PolicyIgnore +from pyodata.config import Config -@responses.activate -def test_invalid_odata_version(): - """Check handling of request for invalid OData version implementation""" +from pyodata.v2.service import Service +from pyodata.v2 import ODataV2 - with pytest.raises(PyODataException) as e_info: - pyodata.Client(SERVICE_URL, requests, 'INVALID VERSION') - - assert str(e_info.value).startswith('No implementation for selected odata version') +SERVICE_URL = 'http://example.com' @responses.activate @@ -46,13 +41,13 @@ def test_create_service_application_xml(metadata): client = pyodata.Client(SERVICE_URL, requests) - assert isinstance(client, pyodata.v2.service.Service) + assert isinstance(client, Service) # onw more test for '/' terminated url client = pyodata.Client(SERVICE_URL + '/', requests) - assert isinstance(client, pyodata.v2.service.Service) + assert isinstance(client, Service) @responses.activate @@ -68,13 +63,13 @@ def test_create_service_text_xml(metadata): client = pyodata.Client(SERVICE_URL, requests) - assert isinstance(client, pyodata.v2.service.Service) + assert isinstance(client, Service) # onw more test for '/' terminated url client = pyodata.Client(SERVICE_URL + '/', requests) - assert isinstance(client, pyodata.v2.service.Service) + assert isinstance(client, Service) @responses.activate @@ -127,6 +122,7 @@ def test_client_custom_configuration(mock_warning, metadata): } custom_config = Config( + ODataV2, xml_namespaces=namespaces, default_error_policy=PolicyFatal(), custom_error_policies={ @@ -145,10 +141,10 @@ def test_client_custom_configuration(mock_warning, metadata): 'Passing namespaces directly is deprecated. Use class Config instead', DeprecationWarning ) - assert isinstance(client, pyodata.v2.service.Service) + assert isinstance(client, Service) assert client.schema.config.namespaces == namespaces client = pyodata.Client(SERVICE_URL, requests, config=custom_config) - assert isinstance(client, pyodata.v2.service.Service) + assert isinstance(client, Service) assert client.schema.config == custom_config diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 00000000..5290fef0 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,61 @@ +from typing import List +import pytest + +from pyodata.config import Config, ODATAVersion +from pyodata.exceptions import PyODataParserError +from pyodata.model.builder import MetadataBuilder +from pyodata.model.elements import Schema, Types + + +def test_from_etree_mixin(metadata): + """Test FromEtreeMixin class""" + + class EmptyODATA(ODATAVersion): + @staticmethod + def from_etree_callbacks(): + return {} + + config = Config(EmptyODATA) + builder = MetadataBuilder(metadata, config=config) + + with pytest.raises(PyODataParserError) as typ_ex_info: + builder.build() + + assert typ_ex_info.value.args[0] == f'{Schema.__name__} is unsupported in {config.odata_version.__name__}' + + +def test_supported_primitive_types(): + """Test handling of unsupported primitive types class""" + + class EmptyODATA(ODATAVersion): + @staticmethod + def supported_primitive_types() -> List[str]: + return [ + 'Edm.Binary' + ] + + config = Config(EmptyODATA) + with pytest.raises(KeyError) as typ_ex_info: + Types.from_name('UnsupportedType', config) + + assert typ_ex_info.value.args[0] == f'Requested primitive type is not supported in this version of ODATA' + + assert Types.from_name('Edm.Binary', config).name == 'Edm.Binary' + + +def test_odata_version_statelessness(): + + class EmptyODATA(ODATAVersion): + @staticmethod + def from_etree_callbacks(): + return {} + + @staticmethod + def supported_primitive_types() -> List[str]: + return [] + + with pytest.raises(RuntimeError) as typ_ex_info: + EmptyODATA() + + assert typ_ex_info.value.args[0] == 'ODATAVersion and its children are intentionally stateless, ' \ + 'therefore you can not create instance of them' diff --git a/tests/test_model_v2.py b/tests/test_model_v2.py index d458a9f8..b3af993a 100644 --- a/tests/test_model_v2.py +++ b/tests/test_model_v2.py @@ -4,11 +4,17 @@ from datetime import datetime, timezone from unittest.mock import patch import pytest -from pyodata.v2.model import Schema, Typ, StructTypeProperty, Types, EntityType, EdmStructTypeSerializer, \ - Association, AssociationSet, EndRole, AssociationSetEndRole, TypeInfo, MetadataBuilder, ParserError, PolicyWarning, \ - PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation -from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError + from tests.conftest import assert_logging_policy +from pyodata.config import Config +from pyodata.model.builder import MetadataBuilder +from pyodata.model.elements import Typ, Types, EntityType, TypeInfo, NullType, NullAssociation, EndRole, \ + AssociationSetEndRole, Schema, StructTypeProperty, AssociationSet, Association +from pyodata.model.type_traits import EdmStructTypeSerializer +from pyodata.policies import ParserError, PolicyWarning, PolicyIgnore, PolicyFatal +from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError +from pyodata.v2 import ODataV2 + def test_edmx(schema): @@ -350,20 +356,22 @@ def test_edmx_complex_type_prop_vh(schema): def test_traits(): """Test individual traits""" + config = Config(ODataV2) + # generic - typ = Types.from_name('Edm.Binary') + typ = Types.from_name('Edm.Binary', config) assert repr(typ.traits) == 'TypTraits' assert typ.traits.to_literal('bincontent') == 'bincontent' assert typ.traits.from_literal('some bin content') == 'some bin content' # string - typ = Types.from_name('Edm.String') + typ = Types.from_name('Edm.String', config) assert repr(typ.traits) == 'EdmStringTypTraits' assert typ.traits.to_literal('Foo Foo') == "'Foo Foo'" assert typ.traits.from_literal("'Alice Bob'") == 'Alice Bob' # bool - typ = Types.from_name('Edm.Boolean') + typ = Types.from_name('Edm.Boolean', config) assert repr(typ.traits) == 'EdmBooleanTypTraits' assert typ.traits.to_literal(True) == 'true' assert typ.traits.from_literal('true') is True @@ -376,17 +384,17 @@ def test_traits(): assert typ.traits.from_json(False) is False # integers - typ = Types.from_name('Edm.Int16') + typ = Types.from_name('Edm.Int16', config) assert repr(typ.traits) == 'EdmIntTypTraits' assert typ.traits.to_literal(23) == '23' assert typ.traits.from_literal('345') == 345 - typ = Types.from_name('Edm.Int32') + typ = Types.from_name('Edm.Int32', config) assert repr(typ.traits) == 'EdmIntTypTraits' assert typ.traits.to_literal(23) == '23' assert typ.traits.from_literal('345') == 345 - typ = Types.from_name('Edm.Int64') + typ = Types.from_name('Edm.Int64', config) assert repr(typ.traits) == 'EdmLongIntTypTraits' assert typ.traits.to_literal(23) == '23L' assert typ.traits.from_literal('345L') == 345 @@ -399,7 +407,7 @@ def test_traits(): assert typ.traits.from_json('0L') == 0 # GUIDs - typ = Types.from_name('Edm.Guid') + typ = Types.from_name('Edm.Guid', config) assert repr(typ.traits) == 'EdmPrefixedTypTraits' assert typ.traits.to_literal('000-0000') == "guid'000-0000'" assert typ.traits.from_literal("guid'1234-56'") == '1234-56' @@ -411,7 +419,9 @@ def test_traits(): def test_traits_datetime(): """Test Edm.DateTime traits""" - typ = Types.from_name('Edm.DateTime') + config = Config(ODataV2) + + typ = Types.from_name('Edm.DateTime', config) assert repr(typ.traits) == 'EdmDateTimeTypTraits' # 1. direction Python -> OData @@ -500,10 +510,12 @@ def test_traits_datetime(): def test_traits_collections(): """Test collection traits""" - typ = Types.from_name('Collection(Edm.Int32)') + config = Config(ODataV2) + + typ = Types.from_name('Collection(Edm.Int32)', config) assert typ.traits.from_json(['23', '34']) == [23, 34] - typ = Types.from_name('Collection(Edm.String)') + typ = Types.from_name('Collection(Edm.String)', config) assert typ.traits.from_json(['Bob', 'Alice']) == ['Bob', 'Alice'] @@ -545,14 +557,16 @@ def test_type_parsing(): def test_types(): """Test Types repository""" + config = Config(ODataV2) + # generic for type_name in ['Edm.Binary', 'Edm.String', 'Edm.Int16', 'Edm.Guid']: - typ = Types.from_name(type_name) + typ = Types.from_name(type_name, config) assert typ.kind == Typ.Kinds.Primitive assert not typ.is_collection # Collection of primitive types - typ = Types.from_name('Collection(Edm.String)') + typ = Types.from_name('Collection(Edm.String)', config) assert repr(typ) == 'Collection(Typ(Edm.String))' assert typ.kind is Typ.Kinds.Primitive assert typ.is_collection @@ -690,7 +704,7 @@ def test_annot_v_l_missing_e_t(mock_warning, xml_builder_factory): ) -@patch('pyodata.v2.model.PolicyIgnore.resolve') +@patch.object(PolicyIgnore, 'resolve') @patch('logging.Logger.warning') def test_annot_v_l_trgt_inv_prop(mock_warning, mock_resolve, xml_builder_factory): """Test correct handling of annotations whose target property does not exist""" @@ -843,6 +857,7 @@ def test_edmx_entity_sets(schema): def test_config_set_default_error_policy(): """ Test configurability of policies """ config = Config( + ODataV2, custom_error_policies={ ParserError.ANNOTATION: PolicyWarning() } @@ -880,6 +895,7 @@ def test_null_type(xml_builder_factory): metadata = MetadataBuilder( xml_builder.serialize(), config=Config( + ODataV2, default_error_policy=PolicyIgnore() )) @@ -927,6 +943,7 @@ def test_faulty_association(xml_builder_factory): metadata = MetadataBuilder( xml_builder.serialize(), config=Config( + ODataV2, default_error_policy=PolicyIgnore() )) @@ -953,6 +970,7 @@ def test_faulty_association_set(xml_builder_factory): metadata = MetadataBuilder( xml_builder.serialize(), config=Config( + ODataV2, default_error_policy=PolicyWarning() )) @@ -1079,6 +1097,7 @@ def test_unsupported_edmx_n(mock_from_etree, xml_builder_factory): MetadataBuilder( xml, config=Config( + ODataV2, xml_namespaces={'edmx': edmx} ) ).build() @@ -1107,6 +1126,7 @@ def test_unsupported_schema_n(mock_from_etree, xml_builder_factory): MetadataBuilder( xml, config=Config( + ODataV2, xml_namespaces={'edm': edm} ) ).build() @@ -1232,7 +1252,7 @@ def test_enum_value_out_of_range(xml_builder_factory): try: MetadataBuilder(xml).build() - except PyODataParserError as ex: + except BaseException as ex: assert str(ex) == f'Value -130 is out of range for type Edm.Byte' @@ -1286,6 +1306,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac 'an non existing LocalDataProperty --- of EntityType(MasterEntity)' MetadataBuilder(xml, Config( + ODataV2, default_error_policy=PolicyWarning() )).build() @@ -1307,6 +1328,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac 'existing ValueListProperty --- of EntityType(DataEntity)' MetadataBuilder(xml, Config( + ODataV2, default_error_policy=PolicyWarning() )).build() @@ -1324,6 +1346,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac mock_warning.reset_mock() MetadataBuilder(xml, Config( + ODataV2, default_error_policy=PolicyWarning() )).build() diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 9bdef81c..ff82288f 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -6,7 +6,6 @@ import pytest from unittest.mock import patch -import pyodata.v2.model import pyodata.v2.service from pyodata.exceptions import PyODataException, HttpError, ExpressionError from pyodata.v2.service import EntityKey, EntityProxy, GetEntitySetFilter From c93cccd33c4c4b4e695dd02a262ec0fc1ba5ba21 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 11 Oct 2019 10:26:55 +0200 Subject: [PATCH 02/36] Add separate type repository for each child of ODATAVersion In every release of OData new types are not only added and removed but also there are changes to the existing types notably in formatting. Thus, there is a need to have separate type repository for each OData version. Let's see an example of Edm.Double JSON format: OData V2: 3.141d OData V4: 3.141 https://www.odata.org/documentation/odata-version-2-0/overview/#AbstractTypeSystem http://docs.oasis-open.org/odata/odata-json-format/v4.01/odata-json-format-v4.01.html --- CHANGELOG.md | 1 + pyodata/config.py | 16 +++++---- pyodata/model/elements.py | 68 +++++++++++---------------------------- pyodata/v2/__init__.py | 36 +++++++++++---------- tests/test_model.py | 31 +++++++++++++++--- 5 files changed, 74 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb3ee2f..ccca30da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Client can be created from local metadata - Jakub Filak - support all standard EDM schema versions - Jakub Filak - Splits python representation of metadata and metadata parsing - Martin Miksik +- Separate type repositories for individual versions of OData - Martin Miksik ### Fixed - make sure configured error policies are applied for Annotations referencing diff --git a/pyodata/config.py b/pyodata/config.py index f6854aa1..c491f710 100644 --- a/pyodata/config.py +++ b/pyodata/config.py @@ -1,10 +1,14 @@ """ Contains definition of configuration class for PyOData and for ODATA versions. """ from abc import ABC, abstractmethod -from typing import Type, List, Dict, Callable +from typing import Type, List, Dict, Callable, TYPE_CHECKING from pyodata.policies import PolicyFatal, ParserError, ErrorPolicy +# pylint: disable=cyclic-import +if TYPE_CHECKING: + from pyodata.model.elements import Typ # noqa + class ODATAVersion(ABC): """ This is base class for different OData releases. In it we define what are supported types, elements and so on. @@ -15,9 +19,12 @@ def __init__(self): raise RuntimeError('ODATAVersion and its children are intentionally stateless, ' 'therefore you can not create instance of them') + # Separate dictionary of all registered types (primitive, complex and collection variants) for each child + Types: Dict[str, 'Typ'] = dict() + @staticmethod @abstractmethod - def supported_primitive_types() -> List[str]: + def primitive_types() -> List['Typ']: """ Here we define which primitive types are supported and what is their python representation""" @staticmethod @@ -25,11 +32,6 @@ def supported_primitive_types() -> List[str]: def from_etree_callbacks() -> Dict[object, Callable]: """ Here we define which elements are supported and what is their python representation""" - @classmethod - def is_primitive_type_supported(cls, type_name): - """ Convenience method which decides whatever given type is supported.""" - return type_name in cls.supported_primitive_types() - class Config: # pylint: disable=too-many-instance-attributes,missing-docstring diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 9d97e78c..6716117e 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -8,8 +8,7 @@ from pyodata.config import Config from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError -from pyodata.model.type_traits import EdmBooleanTypTraits, EdmDateTimeTypTraits, EdmPrefixedTypTraits, \ - EdmIntTypTraits, EdmLongIntTypTraits, EdmStringTypTraits, TypTraits, EdmStructTypTraits, EnumTypTrait +from pyodata.model.type_traits import TypTraits, EdmStructTypTraits, EnumTypTrait IdentifierInfo = collections.namedtuple('IdentifierInfo', 'namespace name') @@ -80,7 +79,7 @@ def parse(value): class Types: - """Repository of all available OData types + """ Repository of all available OData types in given version Since each type has instance of appropriate type, this repository acts as central storage for all instances. The @@ -88,61 +87,31 @@ class Types: always reuse existing instances if possible """ - # dictionary of all registered types (primitive, complex and collection variants) - Types = None - @staticmethod - def _build_types(): - """Create and register instances of all primitive Edm types""" - - if Types.Types is None: - Types.Types = {} - - Types.register_type(Typ('Null', 'null')) - Types.register_type(Typ('Edm.Binary', 'binary\'\'')) - Types.register_type(Typ('Edm.Boolean', 'false', EdmBooleanTypTraits())) - Types.register_type(Typ('Edm.Byte', '0')) - Types.register_type(Typ('Edm.DateTime', 'datetime\'2000-01-01T00:00\'', EdmDateTimeTypTraits())) - Types.register_type(Typ('Edm.Decimal', '0.0M')) - Types.register_type(Typ('Edm.Double', '0.0d')) - Types.register_type(Typ('Edm.Single', '0.0f')) - Types.register_type( - Typ('Edm.Guid', 'guid\'00000000-0000-0000-0000-000000000000\'', EdmPrefixedTypTraits('guid'))) - Types.register_type(Typ('Edm.Int16', '0', EdmIntTypTraits())) - Types.register_type(Typ('Edm.Int32', '0', EdmIntTypTraits())) - Types.register_type(Typ('Edm.Int64', '0L', EdmLongIntTypTraits())) - Types.register_type(Typ('Edm.SByte', '0')) - Types.register_type(Typ('Edm.String', '\'\'', EdmStringTypTraits())) - Types.register_type(Typ('Edm.Time', 'time\'PT00H00M\'')) - Types.register_type(Typ('Edm.DateTimeOffset', 'datetimeoffset\'0000-00-00T00:00:00\'')) + def register_type(typ: 'Typ', config: Config): + """Add new type to the ODATA version type repository as well as its collection variant""" - @staticmethod - def register_type(typ): - """Add new type to the type repository as well as its collection variant""" - - # build types hierarchy on first use (lazy creation) - if Types.Types is None: - Types._build_types() + o_version = config.odata_version # register type only if it doesn't exist - # pylint: disable=unsupported-membership-test - if typ.name not in Types.Types: - # pylint: disable=unsupported-assignment-operation - Types.Types[typ.name] = typ + if typ.name not in o_version.Types: + o_version.Types[typ.name] = typ # automatically create and register collection variant if not exists collection_name = 'Collection({})'.format(typ.name) - # pylint: disable=unsupported-membership-test - if collection_name not in Types.Types: + if collection_name not in o_version.Types: collection_typ = Collection(typ.name, typ) - # pylint: disable=unsupported-assignment-operation - Types.Types[collection_name] = collection_typ + o_version.Types[collection_name] = collection_typ @staticmethod def from_name(name, config: Config): + o_version = config.odata_version + # build types hierarchy on first use (lazy creation) - if Types.Types is None: - Types._build_types() + if not o_version.Types: + o_version.Types = dict() + for typ in o_version.primitive_types(): + Types.register_type(typ, config) search_name = name @@ -152,12 +121,11 @@ def from_name(name, config: Config): name = name[11:-1] # strip collection() decorator search_name = 'Collection({})'.format(name) - if not config.odata_version.is_primitive_type_supported(name): + try: + return o_version.Types[search_name] + except KeyError: raise KeyError('Requested primitive type is not supported in this version of ODATA') - # pylint: disable=unsubscriptable-object - return Types.Types[search_name] - @staticmethod def parse_type_name(type_name): diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index d6b5f2b3..95a61ad0 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -4,6 +4,8 @@ import logging from typing import List +from pyodata.model.type_traits import EdmBooleanTypTraits, EdmDateTimeTypTraits, EdmPrefixedTypTraits, \ + EdmIntTypTraits, EdmLongIntTypTraits, EdmStringTypTraits from pyodata.policies import ParserError from pyodata.config import ODATAVersion, Config from pyodata.exceptions import PyODataParserError, PyODataModelError @@ -52,24 +54,24 @@ def from_etree_callbacks(): } @staticmethod - def supported_primitive_types() -> List[str]: + def primitive_types() -> List[Typ]: return [ - 'Null', - 'Edm.Binary', - 'Edm.Boolean', - 'Edm.Byte', - 'Edm.DateTime', - 'Edm.Decimal', - 'Edm.Double', - 'Edm.Single', - 'Edm.Guid', - 'Edm.Int16', - 'Edm.Int32', - 'Edm.Int64', - 'Edm.SByte', - 'Edm.String', - 'Edm.Time', - 'Edm.DateTimeOffset', + Typ('Null', 'null'), + Typ('Edm.Binary', 'binary\'\''), + Typ('Edm.Boolean', 'false', EdmBooleanTypTraits()), + Typ('Edm.Byte', '0'), + Typ('Edm.DateTime', 'datetime\'2000-01-01T00:00\'', EdmDateTimeTypTraits()), + Typ('Edm.Decimal', '0.0M'), + Typ('Edm.Double', '0.0d'), + Typ('Edm.Single', '0.0f'), + Typ('Edm.Guid', 'guid\'00000000-0000-0000-0000-000000000000\'', EdmPrefixedTypTraits('guid')), + Typ('Edm.Int16', '0', EdmIntTypTraits()), + Typ('Edm.Int32', '0', EdmIntTypTraits()), + Typ('Edm.Int64', '0L', EdmLongIntTypTraits()), + Typ('Edm.SByte', '0'), + Typ('Edm.String', '\'\'', EdmStringTypTraits()), + Typ('Edm.Time', 'time\'PT00H00M\''), + Typ('Edm.DateTimeOffset', 'datetimeoffset\'0000-00-00T00:00:00\'') ] # pylint: disable=too-many-locals,too-many-branches,too-many-statements, protected-access,missing-docstring diff --git a/tests/test_model.py b/tests/test_model.py index 5290fef0..de6edd99 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -4,7 +4,8 @@ from pyodata.config import Config, ODATAVersion from pyodata.exceptions import PyODataParserError from pyodata.model.builder import MetadataBuilder -from pyodata.model.elements import Schema, Types +from pyodata.model.elements import Schema, Types, Typ +from v2 import ODataV2 def test_from_etree_mixin(metadata): @@ -29,9 +30,9 @@ def test_supported_primitive_types(): class EmptyODATA(ODATAVersion): @staticmethod - def supported_primitive_types() -> List[str]: + def primitive_types() -> List[Typ]: return [ - 'Edm.Binary' + Typ('Edm.Binary', 'binary\'\'') ] config = Config(EmptyODATA) @@ -51,7 +52,7 @@ def from_etree_callbacks(): return {} @staticmethod - def supported_primitive_types() -> List[str]: + def primitive_types() -> List[Typ]: return [] with pytest.raises(RuntimeError) as typ_ex_info: @@ -59,3 +60,25 @@ def supported_primitive_types() -> List[str]: assert typ_ex_info.value.args[0] == 'ODATAVersion and its children are intentionally stateless, ' \ 'therefore you can not create instance of them' + + +def test_types_repository_separation(): + + class TestODATA(ODATAVersion): + @staticmethod + def primitive_types() -> List['Typ']: + return [ + Typ('PrimitiveType', '0') + ] + + config_test = Config(TestODATA) + config_v2 = Config(ODataV2) + + assert TestODATA.Types is None + assert TestODATA.Types == ODataV2.Types + + # Build type repository by initial call + Types.from_name('PrimitiveType', config_test) + Types.from_name('Edm.Int16', config_v2) + + assert TestODATA.Types != ODataV2.Types \ No newline at end of file From 2b0f622238c22d9ba984b738c4d29bd1a8adc8f9 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 11 Oct 2019 10:37:20 +0200 Subject: [PATCH 03/36] Add support for OData V4 primitive types https://www.odata.org/documentation/ --- .travis.yml | 1 + CHANGELOG.md | 1 + optional-requirements.txt | 1 + pyodata/model/type_traits.py | 82 ------------ pyodata/v2/__init__.py | 6 +- pyodata/v2/type_traits.py | 88 ++++++++++++ pyodata/v4/__init__.py | 58 ++++++++ pyodata/v4/type_traits.py | 253 +++++++++++++++++++++++++++++++++++ tests/test_model.py | 2 +- tests/test_model_v4.py | 142 ++++++++++++++++++++ 10 files changed, 549 insertions(+), 85 deletions(-) create mode 100644 optional-requirements.txt create mode 100644 pyodata/v2/type_traits.py create mode 100644 pyodata/v4/__init__.py create mode 100644 pyodata/v4/type_traits.py create mode 100644 tests/test_model_v4.py diff --git a/.travis.yml b/.travis.yml index 27c77860..8035bb2d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,7 @@ install: - pip install . - pip install -r dev-requirements.txt - pip install -r requirements.txt + - pip install -r optional-requirements.txt - pip install bandit # command to run tests diff --git a/CHANGELOG.md b/CHANGELOG.md index ccca30da..9c0c807f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - support all standard EDM schema versions - Jakub Filak - Splits python representation of metadata and metadata parsing - Martin Miksik - Separate type repositories for individual versions of OData - Martin Miksik +- Support for OData V4 primitive types - Martin Miksik ### Fixed - make sure configured error policies are applied for Annotations referencing diff --git a/optional-requirements.txt b/optional-requirements.txt new file mode 100644 index 00000000..e116fb3c --- /dev/null +++ b/optional-requirements.txt @@ -0,0 +1 @@ +geojson diff --git a/pyodata/model/type_traits.py b/pyodata/model/type_traits.py index 3bd59aea..a3c6e74c 100644 --- a/pyodata/model/type_traits.py +++ b/pyodata/model/type_traits.py @@ -1,6 +1,5 @@ # pylint: disable=missing-docstring -import datetime import re from pyodata.exceptions import PyODataException, PyODataModelError @@ -99,87 +98,6 @@ def from_literal(self, value): return matches.group(1) -class EdmDateTimeTypTraits(EdmPrefixedTypTraits): - """Emd.DateTime traits - - Represents date and time with values ranging from 12:00:00 midnight, - January 1, 1753 A.D. through 11:59:59 P.M, December 9999 A.D. - - Literal form: - datetime'yyyy-mm-ddThh:mm[:ss[.fffffff]]' - NOTE: Spaces are not allowed between datetime and quoted portion. - datetime is case-insensitive - - Example 1: datetime'2000-12-12T12:00' - JSON has following format: /Date(1516614510000)/ - https://blogs.sap.com/2017/01/05/date-and-time-in-sap-gateway-foundation/ - """ - - def __init__(self): - super(EdmDateTimeTypTraits, self).__init__('datetime') - - def to_literal(self, value): - """Convert python datetime representation to literal format - - None: this could be done also via formatting string: - value.strftime('%Y-%m-%dT%H:%M:%S.%f') - """ - - if not isinstance(value, datetime.datetime): - raise PyODataModelError( - 'Cannot convert value of type {} to literal. Datetime format is required.'.format(type(value))) - - return super(EdmDateTimeTypTraits, self).to_literal(value.replace(tzinfo=None).isoformat()) - - def to_json(self, value): - if isinstance(value, str): - return value - - # Converts datetime into timestamp in milliseconds in UTC timezone as defined in ODATA specification - # https://www.odata.org/documentation/odata-version-2-0/json-format/ - return f'/Date({int(value.replace(tzinfo=datetime.timezone.utc).timestamp()) * 1000})/' - - def from_json(self, value): - - if value is None: - return None - - matches = re.match(r"^/Date\((.*)\)/$", value) - if not matches: - raise PyODataModelError( - "Malformed value {0} for primitive Edm type. Expected format is /Date(value)/".format(value)) - value = matches.group(1) - - try: - # https://stackoverflow.com/questions/36179914/timestamp-out-of-range-for-platform-localtime-gmtime-function - value = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta( - milliseconds=int(value)) - except ValueError: - raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) - - return value - - def from_literal(self, value): - - if value is None: - return None - - value = super(EdmDateTimeTypTraits, self).from_literal(value) - - try: - value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') - except ValueError: - try: - value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S') - except ValueError: - try: - value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M') - except ValueError: - raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) - - return value - - class EdmStringTypTraits(TypTraits): """Edm.String traits""" diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index 95a61ad0..fdda8d27 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -4,8 +4,10 @@ import logging from typing import List -from pyodata.model.type_traits import EdmBooleanTypTraits, EdmDateTimeTypTraits, EdmPrefixedTypTraits, \ - EdmIntTypTraits, EdmLongIntTypTraits, EdmStringTypTraits +from pyodata.v2.type_traits import EdmDateTimeTypTraits + +from pyodata.model.type_traits import EdmBooleanTypTraits, EdmPrefixedTypTraits, EdmIntTypTraits, \ + EdmLongIntTypTraits, EdmStringTypTraits from pyodata.policies import ParserError from pyodata.config import ODATAVersion, Config from pyodata.exceptions import PyODataParserError, PyODataModelError diff --git a/pyodata/v2/type_traits.py b/pyodata/v2/type_traits.py new file mode 100644 index 00000000..caf6ad4c --- /dev/null +++ b/pyodata/v2/type_traits.py @@ -0,0 +1,88 @@ +""" Type traits for types specific to the ODATA V4""" + +import datetime +import re + +from pyodata.exceptions import PyODataModelError +from pyodata.model.type_traits import EdmPrefixedTypTraits + + +class EdmDateTimeTypTraits(EdmPrefixedTypTraits): + """Emd.DateTime traits + + Represents date and time with values ranging from 12:00:00 midnight, + January 1, 1753 A.D. through 11:59:59 P.M, December 9999 A.D. + + Literal form: + datetime'yyyy-mm-ddThh:mm[:ss[.fffffff]]' + NOTE: Spaces are not allowed between datetime and quoted portion. + datetime is case-insensitive + + Example 1: datetime'2000-12-12T12:00' + JSON has following format: /Date(1516614510000)/ + https://blogs.sap.com/2017/01/05/date-and-time-in-sap-gateway-foundation/ + """ + + def __init__(self): + super(EdmDateTimeTypTraits, self).__init__('datetime') + + def to_literal(self, value): + """Convert python datetime representation to literal format + + None: this could be done also via formatting string: + value.strftime('%Y-%m-%dT%H:%M:%S.%f') + """ + + if not isinstance(value, datetime.datetime): + raise PyODataModelError( + 'Cannot convert value of type {} to literal. Datetime format is required.'.format(type(value))) + + return super(EdmDateTimeTypTraits, self).to_literal(value.replace(tzinfo=None).isoformat()) + + def to_json(self, value): + if isinstance(value, str): + return value + + # Converts datetime into timestamp in milliseconds in UTC timezone as defined in ODATA specification + # https://www.odata.org/documentation/odata-version-2-0/json-format/ + return f'/Date({int(value.replace(tzinfo=datetime.timezone.utc).timestamp()) * 1000})/' + + def from_json(self, value): + + if value is None: + return None + + matches = re.match(r"^/Date\((.*)\)/$", value) + if not matches: + raise PyODataModelError( + "Malformed value {0} for primitive Edm type. Expected format is /Date(value)/".format(value)) + value = matches.group(1) + + try: + # https://stackoverflow.com/questions/36179914/timestamp-out-of-range-for-platform-localtime-gmtime-function + value = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta( + milliseconds=int(value)) + except ValueError: + raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) + + return value + + def from_literal(self, value): + + if value is None: + return None + + value = super(EdmDateTimeTypTraits, self).from_literal(value) + + try: + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') + except ValueError: + try: + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S') + except ValueError: + try: + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M') + except ValueError: + raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) + + return value diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py new file mode 100644 index 00000000..faaee46c --- /dev/null +++ b/pyodata/v4/__init__.py @@ -0,0 +1,58 @@ +""" This module represents implementation of ODATA V4 """ + +from typing import List + +from pyodata.config import ODATAVersion +from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits +from pyodata.model.elements import Typ +from pyodata.v4.type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ + EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration + + +class ODataV4(ODATAVersion): + """ Definition of OData V4 """ + + @staticmethod + def from_etree_callbacks(): + return { + } + + @staticmethod + def primitive_types() -> List[Typ]: + # TODO: We currently lack support for: + # 'Edm.Geometry', + # 'Edm.GeometryPoint', + # 'Edm.GeometryLineString', + # 'Edm.GeometryPolygon', + # 'Edm.GeometryMultiPoint', + # 'Edm.GeometryMultiLineString', + # 'Edm.GeometryMultiPolygon', + # 'Edm.GeometryCollection', + + return [ + Typ('Null', 'null'), + Typ('Edm.Binary', '', EdmDoubleQuotesEncapsulatedTypTraits()), + Typ('Edm.Boolean', 'false', EdmBooleanTypTraits()), + Typ('Edm.Byte', '0'), + Typ('Edm.Date', '0000-00-00', EdmDateTypTraits()), + Typ('Edm.Decimal', '0.0'), + Typ('Edm.Double', '0.0'), + Typ('Edm.Duration', 'P', EdmDuration()), + Typ('Edm.Stream', 'null', EdmDoubleQuotesEncapsulatedTypTraits()), + Typ('Edm.Single', '0.0', EdmDoubleQuotesEncapsulatedTypTraits()), + Typ('Edm.Guid', '\"00000000-0000-0000-0000-000000000000\"', EdmDoubleQuotesEncapsulatedTypTraits()), + Typ('Edm.Int16', '0', EdmIntTypTraits()), + Typ('Edm.Int32', '0', EdmIntTypTraits()), + Typ('Edm.Int64', '0', EdmIntTypTraits()), + Typ('Edm.SByte', '0'), + Typ('Edm.String', '\"\"', EdmDoubleQuotesEncapsulatedTypTraits()), + Typ('Edm.TimeOfDay', '00:00:00', EdmTimeOfDay()), + Typ('Edm.DateTimeOffset', '0000-00-00T00:00:00', EdmDateTimeOffsetTypTraits()), + Typ('Edm.Geography', '', GeoTypeTraits()), + Typ('Edm.GeographyPoint', '', GeoTypeTraits()), + Typ('Edm.GeographyLineString', '', GeoTypeTraits()), + Typ('Edm.GeographyPolygon', '', GeoTypeTraits()), + Typ('Edm.GeographyMultiPoint', '', GeoTypeTraits()), + Typ('Edm.GeographyMultiLineString', '', GeoTypeTraits()), + Typ('Edm.GeographyMultiPolygon', '', GeoTypeTraits()), + ] diff --git a/pyodata/v4/type_traits.py b/pyodata/v4/type_traits.py new file mode 100644 index 00000000..6125be4a --- /dev/null +++ b/pyodata/v4/type_traits.py @@ -0,0 +1,253 @@ +""" Type traits for types specific to the ODATA V4""" + +import datetime + +# In case you want to use geojson types. You have to install pip package 'geojson' +from collections import namedtuple + +try: + import geojson + GEOJSON_MODULE = True +except ImportError: + GEOJSON_MODULE = False + +from pyodata.exceptions import PyODataModelError, PyODataException +from pyodata.model.type_traits import TypTraits + + +class EdmDoubleQuotesEncapsulatedTypTraits(TypTraits): + """Good for all types which are encapsulated in double quotes""" + + def to_literal(self, value): + return '\"%s\"' % (value) + + def to_json(self, value): + return self.to_literal(value) + + def from_literal(self, value): + return value.strip('\"') + + def from_json(self, value): + return self.from_literal(value) + + +class EdmDateTypTraits(EdmDoubleQuotesEncapsulatedTypTraits): + """Emd.Date traits + Date is new type in ODATA V4. According to found resources the literal and json form is unified and is + complaint with iso format. + + http://docs.oasis-open.org/odata/odata/v4.0/errata02/os/complete/part3-csdl/odata-v4.0-errata02-os-part3-csdl-complete.html#_Toc406397943 + https://www.w3.org/TR/2012/REC-xmlschema11-2-20120405/#date + """ + + def to_literal(self, value: datetime.date): + if not isinstance(value, datetime.date): + raise PyODataModelError( + 'Cannot convert value of type {} to literal. Date format is required.'.format(type(value))) + + return super().to_literal(value.isoformat()) + + def to_json(self, value: datetime.date): + return self.to_literal(value) + + def from_literal(self, value: str): + if value is None: + return None + + try: + return datetime.date.fromisoformat(super().from_literal(value)) + except ValueError: + raise PyODataModelError('Cannot decode date from value {}.'.format(value)) + + def from_json(self, value: str): + return self.from_literal(value) + + +class EdmTimeOfDay(EdmDoubleQuotesEncapsulatedTypTraits): + """ Emd.TimeOfDay traits + + Represents time without timezone information + JSON and literal format: "hh:mm:ss.s" + + JSON example: + "TimeOfDayValue": "07:59:59.999" + """ + + def to_literal(self, value: datetime.time): + if not isinstance(value, datetime.time): + raise PyODataModelError( + 'Cannot convert value of type {} to literal. Time format is required.'.format(type(value))) + + return super().to_literal(value.replace(tzinfo=None).isoformat()) + + def to_json(self, value: datetime.time): + return self.to_literal(value) + + def from_literal(self, value: str): + if value is None: + return None + + try: + return datetime.time.fromisoformat(super().from_literal(value)) + except ValueError: + raise PyODataModelError('Cannot decode date from value {}.'.format(value)) + + def from_json(self, value: str): + return self.from_literal(value) + + +class EdmDuration(TypTraits): + """ Emd.Duration traits + + Represents time duration as described in xml specification (https://www.w3.org/TR/xmlschema11-2/#duration) + JSON and literal format is variable e. g. + - P2Y6M5DT12H35M30S => 2 years, 6 months, 5 days, 12 hours, 35 minutes, 30 seconds + - P1DT2H => 1 day, 2 hours + + http://www.datypic.com/sc/xsd/t-xsd_duration.html + + As python has no native way to represent duration we simply return int which represents duration in seconds + For more advance operations with duration you can use datetimeutils module from pip + """ + + Duration = namedtuple('Duration', 'year month day hour minute second') + + def to_literal(self, value: Duration) -> str: + result = 'P' + + if not isinstance(value, EdmDuration.Duration): + raise PyODataModelError(f'Cannot convert value of type {type(value)}. Duration format is required.') + + if value.year > 0: + result += f'{value.year}Y' + + if value.month > 0: + result += f'{value.month}M' + + if value.day > 0: + result += f'{value.day}D' + + if value.hour > 0 or value.minute > 0 or value.second > 0: + result += 'T' + + if value.hour: + result += f'{value.hour}H' + + if value.minute > 0: + result += f'{value.minute}M' + + if value.second > 0: + result += f'{value.second}S' + + return result + + def to_json(self, value: Duration) -> str: + return self.to_literal(value) + + def from_literal(self, value: str) -> 'Duration': + value = value[1:] + time_part = False + offset = 0 + year, month, day, hour, minute, second = 0, 0, 0, 0, 0, 0 + + for index, char in enumerate(value): + if char == 'T': + offset += 1 + time_part = True + elif char.isalpha(): + count = int(value[offset:index]) + + if char == 'Y': + year = count + elif char == 'M' and not time_part: + month = count + elif char == 'D': + day = count + elif char == 'H': + hour = count + elif char == 'M': + minute = count + elif char == 'S': + second = count + + offset = index + 1 + + return EdmDuration.Duration(year, month, day, hour, minute, second) + + def from_json(self, value: str) -> 'Duration': + return self.from_literal(value) + + +class EdmDateTimeOffsetTypTraits(EdmDoubleQuotesEncapsulatedTypTraits): + """ Emd.DateTimeOffset traits + + Represents date and time with timezone information + JSON and literal format: " YYYY-MM-DDThh:mm:ss.sTZD" + + JSON example: + "DateTimeOffsetValue": "2012-12-03T07:16:23Z", + + https://www.w3.org/TR/NOTE-datetime + """ + + def to_literal(self, value: datetime.datetime): + """Convert python datetime representation to literal format""" + + if not isinstance(value, datetime.datetime): + raise PyODataModelError( + 'Cannot convert value of type {} to literal. Datetime format is required.'.format(type(value))) + + if value.tzinfo is None: + raise PyODataModelError( + 'Datetime pass without explicitly setting timezone. You need to provide timezone information for valid' + ' Emd.DateTimeOffset') + + # https://www.w3.org/TR/NOTE-datetime => + # "Times are expressed in UTC (Coordinated Universal Time), with a special UTC designator ("Z")." + # "Z" is preferred by ODATA documentation too in contrast to +00:00 + + if value.tzinfo == datetime.timezone.utc: + return super().to_literal(value.replace(tzinfo=None).isoformat() + 'Z') + + return super().to_literal(value.isoformat()) + + def to_json(self, value: datetime.datetime): + return self.to_literal(value) + + def from_literal(self, value: str): + + value = super().from_literal(value) + + try: + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f%z') + except ValueError: + try: + value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z') + except ValueError: + raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) + + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.timezone.utc) + + return value + + def from_json(self, value: str): + return self.from_literal(value) + + +class GeoTypeTraits(TypTraits): + """ Edm.Geography XXX + Represents elements which are complaint with geojson specification + """ + + def __getattribute__(self, item): + if not GEOJSON_MODULE: + raise PyODataException('To use geography types you need to install pip package geojson') + + return object.__getattribute__(self, item) + + def from_json(self, value: str) -> 'geojson.GeoJSON': + return geojson.loads(value) + + def to_json(self, value: 'geojson.GeoJSON') -> str: + return geojson.dumps(value) diff --git a/tests/test_model.py b/tests/test_model.py index de6edd99..f726bfae 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -5,7 +5,7 @@ from pyodata.exceptions import PyODataParserError from pyodata.model.builder import MetadataBuilder from pyodata.model.elements import Schema, Types, Typ -from v2 import ODataV2 +from pyodata.v2 import ODataV2 def test_from_etree_mixin(metadata): diff --git a/tests/test_model_v4.py b/tests/test_model_v4.py new file mode 100644 index 00000000..acbd1e9d --- /dev/null +++ b/tests/test_model_v4.py @@ -0,0 +1,142 @@ +import json +import datetime +import geojson +import pytest + +from pyodata.exceptions import PyODataModelError +from pyodata.model.elements import Types + +from pyodata.config import Config +from pyodata.v4 import ODataV4 + + +def test_type_traits(): + """Test traits""" + # https://docs.oasis-open.org/odata/odata-json-format/v4.01/csprd05/odata-json-format-v4.01-csprd05.html#sec_PrimitiveValue + + config = Config(ODataV4) + + traits = Types.from_name('Edm.Date', config).traits + test_date = datetime.date(2005, 1, 28) + test_date_json = traits.to_json(test_date) + assert test_date_json == '\"2005-01-28\"' + assert test_date == traits.from_json(test_date_json) + + traits = Types.from_name('Edm.TimeOfDay', config).traits + test_time = datetime.time(7, 59, 59) + test_time_json = traits.to_json(test_time) + assert test_time_json == '\"07:59:59\"' + assert test_time == traits.from_json(test_time_json) + + traits = Types.from_name('Edm.DateTimeOffset', config).traits + test_date_time_offset = datetime.datetime(2012, 12, 3, 7, 16, 23, tzinfo=datetime.timezone.utc) + test_date_time_offset_json = traits.to_json(test_date_time_offset) + assert test_date_time_offset_json == '\"2012-12-03T07:16:23Z\"' + assert test_date_time_offset == traits.from_json(test_date_time_offset_json) + assert test_date_time_offset == traits.from_json('\"2012-12-03T07:16:23+00:00\"') + + # serialization of invalid value + with pytest.raises(PyODataModelError) as e_info: + traits.to_literal('xyz') + assert str(e_info.value).startswith('Cannot convert value of type') + + traits = Types.from_name('Edm.Duration', config).traits + + test_duration_json = 'P8MT4H' + test_duration = traits.from_json(test_duration_json) + assert test_duration.month == 8 + assert test_duration.hour == 4 + assert test_duration_json == traits.to_json(test_duration) + + test_duration_json = 'P2Y6M5DT12H35M30S' + test_duration = traits.from_json(test_duration_json) + assert test_duration.year == 2 + assert test_duration.month == 6 + assert test_duration.day == 5 + assert test_duration.hour == 12 + assert test_duration.minute == 35 + assert test_duration.second == 30 + assert test_duration_json == traits.to_json(test_duration) + + # GeoJson Point + + json_point = json.dumps({ + "type": "Point", + "coordinates": [-118.4080, 33.9425] + }) + + traits = Types.from_name('Edm.GeographyPoint', config).traits + point = traits.from_json(json_point) + + assert isinstance(point, geojson.Point) + assert json_point == traits.to_json(point) + + # GeoJson MultiPoint + + json_multi_point = json.dumps({ + "type": "MultiPoint", + "coordinates": [[100.0, 0.0], [101.0, 1.0]] + }) + + traits = Types.from_name('Edm.GeographyMultiPoint', config).traits + multi_point = traits.from_json(json_multi_point) + + assert isinstance(multi_point, geojson.MultiPoint) + assert json_multi_point == traits.to_json(multi_point) + + # GeoJson LineString + + json_line_string = json.dumps({ + "type": "LineString", + "coordinates": [[102.0, 0.0], [103.0, 1.0], [104.0, 0.0], [105.0, 1.0]] + }) + + traits = Types.from_name('Edm.GeographyLineString', config).traits + line_string = traits.from_json(json_line_string) + + assert isinstance(line_string, geojson.LineString) + assert json_line_string == traits.to_json(line_string) + + # GeoJson MultiLineString + + lines = [] + for i in range(10): + lines.append(geojson.utils.generate_random("LineString")['coordinates']) + + multi_line_string = geojson.MultiLineString(lines) + json_multi_line_string = geojson.dumps(multi_line_string) + traits = Types.from_name('Edm.GeographyMultiLineString', config).traits + + assert multi_line_string == traits.from_json(json_multi_line_string) + assert json_multi_line_string == traits.to_json(multi_line_string) + + # GeoJson Polygon + + json_polygon = json.dumps({ + "type": "Polygon", + "coordinates": [ + [[100.0, 0.0], [105.0, 0.0], [100.0, 1.0]], + [[100.2, 0.2], [103.0, 0.2], [100.3, 0.8]] + ] + }) + + traits = Types.from_name('Edm.GeographyPolygon', config).traits + polygon = traits.from_json(json_polygon) + + assert isinstance(polygon, geojson.Polygon) + assert json_polygon == traits.to_json(polygon) + + # GeoJson MultiPolygon + + lines = [] + for i in range(10): + lines.append(geojson.utils.generate_random("Polygon")['coordinates']) + + multi_polygon = geojson.MultiLineString(lines) + json_multi_polygon = geojson.dumps(multi_polygon) + traits = Types.from_name('Edm.GeographyMultiPolygon', config).traits + + assert multi_polygon == traits.from_json(json_multi_polygon) + assert json_multi_polygon == traits.to_json(multi_polygon) + + From f2b854b1b3520a1a9a78df1023f33e21b320424b Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 11 Oct 2019 14:18:23 +0200 Subject: [PATCH 04/36] Add implementation of schema for OData V4 - Enum types is working - Complex type is working(including BaseType) - Entity type needs editing --- .pylintrc | 4 +- pyodata/model/elements.py | 2 +- pyodata/model/from_etree_callbacks.py | 50 ++-- pyodata/v2/__init__.py | 11 +- pyodata/v2/from_etree_callbacks.py | 9 + pyodata/v4/__init__.py | 12 +- pyodata/v4/from_etree_callbacks.py | 46 ++++ tests/conftest.py | 17 +- tests/metadata_v4.xml | 335 ++++++++++++++++++++++++++ tests/test_client.py | 16 +- tests/test_model.py | 4 +- tests/test_model_v4.py | 10 + 12 files changed, 475 insertions(+), 41 deletions(-) create mode 100644 pyodata/v2/from_etree_callbacks.py create mode 100644 pyodata/v4/from_etree_callbacks.py create mode 100644 tests/metadata_v4.xml diff --git a/.pylintrc b/.pylintrc index 3d3eb6b9..51c08530 100644 --- a/.pylintrc +++ b/.pylintrc @@ -65,8 +65,10 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=locally-disabled +disable=locally-disabled,duplicate-code +# As parts of definitions are the same even for different versions, +# pylint detects them as duplicate code which it is not. Disabling pylint 'duplicate-code' inside module did not work. [REPORTS] diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 6716117e..bad6b5c6 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -29,7 +29,7 @@ def from_etree(cls, etree, config: Config, **kwargs): raise PyODataParserError(f'{cls.__name__} is unsupported in {config.odata_version.__name__}') if kwargs: - return callback(etree, config, kwargs) + return callback(etree, config, **kwargs) return callback(etree, config) diff --git a/pyodata/model/from_etree_callbacks.py b/pyodata/model/from_etree_callbacks.py index b5c0ff68..9a27a9cb 100644 --- a/pyodata/model/from_etree_callbacks.py +++ b/pyodata/model/from_etree_callbacks.py @@ -1,6 +1,7 @@ """ Reusable implementation of from_etree methods for the most of edm elements """ # pylint: disable=unused-argument, missing-docstring, invalid-name +import copy import logging from pyodata.config import Config @@ -40,12 +41,23 @@ def struct_type_property_from_etree(entity_type_property_node, config: Config): # pylint: disable=protected-access -def struct_type_from_etree(type_node, config: Config, kwargs): +def struct_type_from_etree(type_node, config: Config, typ, schema=None): name = type_node.get('Name') - label = sap_attribute_get_string(type_node, 'label') - is_value_list = sap_attribute_get_bool(type_node, 'value-list', False) + base_type = type_node.get('BaseType') - stype = kwargs['type'](name, label, is_value_list) + if base_type is None: + label = sap_attribute_get_string(type_node, 'label') + is_value_list = sap_attribute_get_bool(type_node, 'value-list', False) + stype = typ(name, label, is_value_list) + else: + base_type = Types.parse_type_name(base_type) + + try: + stype = copy.deepcopy(schema.get_type(base_type)) + except KeyError: + raise PyODataParserError(f'BaseType \'{base_type.name}\' not found in schema') + + stype._name = name for proprty in type_node.xpath('edm:Property', namespaces=config.namespaces): stp = StructTypeProperty.from_etree(proprty, config) @@ -59,23 +71,19 @@ def struct_type_from_etree(type_node, config: Config, kwargs): # all properites are loaded because # there might be links between them. for ctp in list(stype._properties.values()): - ctp.struct_type = stype + if ctp.struct_type is None: # TODO: Is it correct + ctp.struct_type = stype return stype -def navigation_type_property_from_etree(node, config: Config): - return NavigationTypeProperty( - node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship'))) - - -def complex_type_from_etree(etree, config: Config): - return StructType.from_etree(etree, config, type=ComplexType) +def complex_type_from_etree(etree, config: Config, schema=None): + return StructType.from_etree(etree, config, typ=ComplexType, schema=schema) # pylint: disable=protected-access -def entity_type_from_etree(etree, config: Config): - etype = StructType.from_etree(etree, config, type=EntityType) +def entity_type_from_etree(etree, config: Config, schema=None): + etype = StructType.from_etree(etree, config, typ=EntityType, schema=schema) for proprty in etree.xpath('edm:Key/edm:PropertyRef', namespaces=config.namespaces): etype._key.append(etype.proprty(proprty.get('Name'))) @@ -92,14 +100,18 @@ def entity_type_from_etree(etree, config: Config): # pylint: disable=protected-access, too-many-locals -def enum_type_from_etree(type_node, config: Config, kwargs): +def enum_type_from_etree(type_node, config: Config, namespace): ename = type_node.get('Name') is_flags = type_node.get('IsFlags') - namespace = kwargs['namespace'] + # namespace = kwargs['namespace'] underlying_type = type_node.get('UnderlyingType') + # https://docs.oasis-open.org/odata/odata-csdl-json/v4.01/csprd04/odata-csdl-json-v4.01-csprd04.html#sec_EnumerationType + if underlying_type is None: + underlying_type = 'Edm.Int32' + valid_types = { 'Edm.Byte': [0, 2 ** 8 - 1], 'Edm.Int16': [-2 ** 15, 2 ** 15 - 1], @@ -263,8 +275,7 @@ def external_annotation_from_etree(annotations_node, config): yield annot -def annotation_from_etree(target, config, kwargs): - annotation_node = kwargs['annotation_node'] +def annotation_from_etree(target, config, annotation_node): term = annotation_node.get('Term') if term in config.sap_annotation_value_list: @@ -274,13 +285,12 @@ def annotation_from_etree(target, config, kwargs): return None -def value_helper_from_etree(target, config, kwargs): +def value_helper_from_etree(target, config, annotation_node): label = None collection_path = None search_supported = False params_node = None - annotation_node = kwargs['annotation_node'] for prop_value in annotation_node.xpath('edm:Record/edm:PropertyValue', namespaces=config.annotation_namespace): rprop = prop_value.get('Property') if rprop == 'Label': diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index fdda8d27..cf4098a3 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -18,10 +18,13 @@ NullAssociation from pyodata.model.from_etree_callbacks import struct_type_property_from_etree, struct_type_from_etree, \ - navigation_type_property_from_etree, complex_type_from_etree, entity_type_from_etree, enum_type_from_etree, \ - entity_set_from_etree, end_role_from_etree, referential_constraint_from_etree, association_from_etree, \ - association_set_end_role_from_etree, association_set_from_etree, external_annotation_from_etree, \ - annotation_from_etree, value_helper_from_etree, value_helper_parameter_from_etree, function_import_from_etree + entity_type_from_etree, enum_type_from_etree, entity_set_from_etree, end_role_from_etree, \ + referential_constraint_from_etree, association_from_etree, association_set_end_role_from_etree, \ + association_set_from_etree, external_annotation_from_etree, annotation_from_etree, value_helper_from_etree, \ + value_helper_parameter_from_etree, function_import_from_etree, complex_type_from_etree + + +from pyodata.v2.from_etree_callbacks import navigation_type_property_from_etree def modlog(): diff --git a/pyodata/v2/from_etree_callbacks.py b/pyodata/v2/from_etree_callbacks.py new file mode 100644 index 00000000..1090a364 --- /dev/null +++ b/pyodata/v2/from_etree_callbacks.py @@ -0,0 +1,9 @@ +# pylint: disable=missing-docstring,invalid-name,unused-argument + +from pyodata.model.elements import NavigationTypeProperty, Identifier +from pyodata.config import Config + + +def navigation_type_property_from_etree(node, config: Config): + return NavigationTypeProperty( + node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship'))) diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py index faaee46c..0cc396a6 100644 --- a/pyodata/v4/__init__.py +++ b/pyodata/v4/__init__.py @@ -2,12 +2,16 @@ from typing import List +from pyodata.model.from_etree_callbacks import enum_type_from_etree, struct_type_property_from_etree, \ + struct_type_from_etree, complex_type_from_etree from pyodata.config import ODATAVersion from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits -from pyodata.model.elements import Typ +from pyodata.model.elements import Typ, Schema, EnumType, ComplexType, StructType, StructTypeProperty from pyodata.v4.type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration +from pyodata.v4.from_etree_callbacks import schema_from_etree + class ODataV4(ODATAVersion): """ Definition of OData V4 """ @@ -15,6 +19,12 @@ class ODataV4(ODATAVersion): @staticmethod def from_etree_callbacks(): return { + StructTypeProperty: struct_type_property_from_etree, + StructType: struct_type_from_etree, + # NavigationTypeProperty: navigation_type_property_from_etree, + EnumType: enum_type_from_etree, + ComplexType: complex_type_from_etree, + Schema: schema_from_etree, } @staticmethod diff --git a/pyodata/v4/from_etree_callbacks.py b/pyodata/v4/from_etree_callbacks.py new file mode 100644 index 00000000..7bae9cfe --- /dev/null +++ b/pyodata/v4/from_etree_callbacks.py @@ -0,0 +1,46 @@ +# pylint: disable=missing-docstring,invalid-name,unused-argument,protected-access +from pyodata.config import Config +from pyodata.exceptions import PyODataParserError +from pyodata.model.elements import ComplexType, Schema, EnumType, NullType +from pyodata.policies import ParserError + + +def schema_from_etree(schema_nodes, config: Config): + schema = Schema(config) + + # Parse Schema nodes by parts to get over the problem of not-yet known + # entity types referenced by entity sets, function imports and + # annotations. + + # TODO: First, process EnumType, EntityType and ComplexType nodes. + # They have almost no dependencies on other elements. + for schema_node in schema_nodes: + namespace = schema_node.get('Namespace') + decl = Schema.Declaration(namespace) + schema._decls[namespace] = decl + + for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces): + try: + etype = EnumType.from_etree(enum_type, config, namespace=namespace) + except (PyODataParserError, AttributeError) as ex: + config.err_policy(ParserError.ENUM_TYPE).resolve(ex) + etype = NullType(enum_type.get('Name')) + + decl.add_enum_type(etype) + + for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): + try: + ctype = ComplexType.from_etree(complex_type, config, schema=schema) + except (KeyError, AttributeError) as ex: + config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) + ctype = NullType(complex_type.get('Name')) + + decl.add_complex_type(ctype) + + # TODO: resolve types of properties + # TODO: Then, process Associations nodes because they refer EntityTypes and they are referenced by AssociationSets. + # TODO: resolve navigation properties + # TODO: Then, process EntitySet, FunctionImport and AssociationSet nodes. + # TODO: Finally, process Annotation nodes when all Scheme nodes are completely processed. + + return Schema diff --git a/tests/conftest.py b/tests/conftest.py index a5148a19..bd598f25 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,11 +6,20 @@ @pytest.fixture -def metadata(): +def metadata_v2(): + return metadata('metadata.xml') + + +@pytest.fixture +def metadata_v4(): + return metadata('metadata_v4.xml') + + +def metadata(file_name: str): """Example OData metadata""" path_to_current_file = os.path.realpath(__file__) current_directory = os.path.split(path_to_current_file)[0] - path_to_file = os.path.join(current_directory, "metadata.xml") + path_to_file = os.path.join(current_directory, file_name) return open(path_to_file, 'rb').read() @@ -107,12 +116,12 @@ def _data_services_epilogue(self): @pytest.fixture -def schema(metadata): +def schema(metadata_v2): """Parsed metadata""" # pylint: disable=redefined-outer-name - return schema_from_xml(metadata) + return schema_from_xml(metadata_v2) def assert_logging_policy(mock_warning, *args): diff --git a/tests/metadata_v4.xml b/tests/metadata_v4.xml new file mode 100644 index 00000000..fffdc03e --- /dev/null +++ b/tests/metadata_v4.xml @@ -0,0 +1,335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Org.OData.Core.V1.Permission/Read + + + + + + image/jpeg + + + + + + + + + + Org.OData.Core.V1.Permission/Read + + + + + + + + + + + + + + + + + + + + + Org.OData.Core.V1.Permission/Read + + + + + + + + + + + Org.OData.Core.V1.Permission/Read + + + + + + + + + + + + + + + Org.OData.Core.V1.Permission/Read + + + + + + + + + + + + + + + + + + + + + + + + + + + Org.OData.Core.V1.Permission/Read + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Org.OData.Capabilities.V1.SearchExpressions/none + + + + + + + + + + + + + + + + + + + + + + Concurrency + + + + + + + Org.OData.Capabilities.V1.NavigationType/None + + + + + + + Org.OData.Capabilities.V1.NavigationType/Recursive + + + + + + + + + + + Org.OData.Capabilities.V1.SearchExpressions/none + + + + + + + + + Trips + Friends + + + + + + + + + + + + Org.OData.Capabilities.V1.SearchExpressions/none + + + + + + + + + + + + + + + + + + + Org.OData.Capabilities.V1.SearchExpressions/none + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Org.OData.Capabilities.V1.ConformanceLevelType/Advanced + + + + application/json;odata.metadata=full;IEEE754Compatible=false;odata.streaming=true + application/json;odata.metadata=minimal;IEEE754Compatible=false;odata.streaming=true + application/json;odata.metadata=none;IEEE754Compatible=false;odata.streaming=true + + + + + + + contains + endswith + startswith + length + indexof + substring + tolower + toupper + trim + concat + year + month + day + hour + minute + second + round + floor + ceiling + cast + isof + + + + + + \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index 374c573a..14cd1414 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,10 +18,10 @@ @responses.activate -def test_create_client_for_local_metadata(metadata): +def test_create_client_for_local_metadata(metadata_v2): """Check client creation for valid use case with local metadata""" - client = pyodata.Client(SERVICE_URL, requests, metadata=metadata) + client = pyodata.Client(SERVICE_URL, requests, metadata=metadata_v2) assert isinstance(client, pyodata.v2.service.Service) @@ -29,14 +29,14 @@ def test_create_client_for_local_metadata(metadata): @responses.activate -def test_create_service_application_xml(metadata): +def test_create_service_application_xml(metadata_v2): """Check client creation for valid use case with MIME type 'application/xml'""" responses.add( responses.GET, "{0}/$metadata".format(SERVICE_URL), content_type='application/xml', - body=metadata, + body=metadata_v2, status=200) client = pyodata.Client(SERVICE_URL, requests) @@ -51,14 +51,14 @@ def test_create_service_application_xml(metadata): @responses.activate -def test_create_service_text_xml(metadata): +def test_create_service_text_xml(metadata_v2): """Check client creation for valid use case with MIME type 'text/xml'""" responses.add( responses.GET, "{0}/$metadata".format(SERVICE_URL), content_type='text/xml', - body=metadata, + body=metadata_v2, status=200) client = pyodata.Client(SERVICE_URL, requests) @@ -106,14 +106,14 @@ def test_metadata_saml_not_authorized(): @responses.activate @patch('warnings.warn') -def test_client_custom_configuration(mock_warning, metadata): +def test_client_custom_configuration(mock_warning, metadata_v2): """Check client creation for custom configuration""" responses.add( responses.GET, "{0}/$metadata".format(SERVICE_URL), content_type='application/xml', - body=metadata, + body=metadata_v2, status=200) namespaces = { diff --git a/tests/test_model.py b/tests/test_model.py index f726bfae..c3ef11b7 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -8,7 +8,7 @@ from pyodata.v2 import ODataV2 -def test_from_etree_mixin(metadata): +def test_from_etree_mixin(metadata_v2): """Test FromEtreeMixin class""" class EmptyODATA(ODATAVersion): @@ -17,7 +17,7 @@ def from_etree_callbacks(): return {} config = Config(EmptyODATA) - builder = MetadataBuilder(metadata, config=config) + builder = MetadataBuilder(metadata_v2, config=config) with pytest.raises(PyODataParserError) as typ_ex_info: builder.build() diff --git a/tests/test_model_v4.py b/tests/test_model_v4.py index acbd1e9d..a0d85381 100644 --- a/tests/test_model_v4.py +++ b/tests/test_model_v4.py @@ -3,11 +3,13 @@ import geojson import pytest +from model.builder import MetadataBuilder from pyodata.exceptions import PyODataModelError from pyodata.model.elements import Types from pyodata.config import Config from pyodata.v4 import ODataV4 +from tests.conftest import metadata def test_type_traits(): @@ -140,3 +142,11 @@ def test_type_traits(): assert json_multi_polygon == traits.to_json(multi_polygon) +def test_schema(metadata_v4): + meta_builder = MetadataBuilder( + metadata_v4, + config=Config(ODataV4) + ) + + meta_builder.build() + From aa68a10e00cc0b57793a4a2da4a26bafdcc5e3f2 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Mon, 14 Oct 2019 15:07:53 +0200 Subject: [PATCH 05/36] Change the implementation of dynamic dispatch function `from_etree` MixinFromEtree was removed in favour of the function called build_element. This is because A) Not always etree was passed, so the name was misleading. B) The purpose of splitting model and parse function was to be able to reuse certain part of code among different version of ODATA, but that was not fully possible with the previous solution as you had to import given element's class(which could be different for each version). Thus, you would have to introduce unnecessary inherency to solve this. New function build_element takes two positional arguments and rest is kwargs. First one is element's class or element's class name as a string. Element class is preferred as it makes refactoring easier and does not introduce magical strings to the code. The name string option is here to solve those cases when you cant import needed element class due to version differences. Second argument is config. --- CHANGELOG.md | 3 + pyodata/config.py | 3 +- ..._etree_callbacks.py => build_functions.py} | 65 ++-- pyodata/model/builder.py | 4 +- pyodata/model/elements.py | 68 +++-- pyodata/v2/__init__.py | 283 ++---------------- pyodata/v2/build_functions.py | 247 +++++++++++++++ pyodata/v2/from_etree_callbacks.py | 9 - pyodata/v4/__init__.py | 18 +- ..._etree_callbacks.py => build_functions.py} | 16 +- tests/test_model.py | 4 +- tests/test_model_v2.py | 57 ++-- tests/test_model_v4.py | 3 +- 13 files changed, 401 insertions(+), 379 deletions(-) rename pyodata/model/{from_etree_callbacks.py => build_functions.py} (83%) create mode 100644 pyodata/v2/build_functions.py delete mode 100644 pyodata/v2/from_etree_callbacks.py rename pyodata/v4/{from_etree_callbacks.py => build_functions.py} (76%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c0c807f..2e5d8f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Separate type repositories for individual versions of OData - Martin Miksik - Support for OData V4 primitive types - Martin Miksik +### Changed +- Implementation and naming schema of `from_etree` - Martin Miksik + ### Fixed - make sure configured error policies are applied for Annotations referencing unknown type/member - Martin Miksik diff --git a/pyodata/config.py b/pyodata/config.py index c491f710..6ffd5d84 100644 --- a/pyodata/config.py +++ b/pyodata/config.py @@ -22,6 +22,7 @@ def __init__(self): # Separate dictionary of all registered types (primitive, complex and collection variants) for each child Types: Dict[str, 'Typ'] = dict() + @staticmethod @abstractmethod def primitive_types() -> List['Typ']: @@ -29,7 +30,7 @@ def primitive_types() -> List['Typ']: @staticmethod @abstractmethod - def from_etree_callbacks() -> Dict[object, Callable]: + def build_functions() -> Dict[type, Callable]: """ Here we define which elements are supported and what is their python representation""" diff --git a/pyodata/model/from_etree_callbacks.py b/pyodata/model/build_functions.py similarity index 83% rename from pyodata/model/from_etree_callbacks.py rename to pyodata/model/build_functions.py index 9a27a9cb..f1b9a292 100644 --- a/pyodata/model/from_etree_callbacks.py +++ b/pyodata/model/build_functions.py @@ -1,4 +1,4 @@ -""" Reusable implementation of from_etree methods for the most of edm elements """ +""" Reusable implementation of build functions for the most of edm elements """ # pylint: disable=unused-argument, missing-docstring, invalid-name import copy @@ -7,17 +7,16 @@ from pyodata.config import Config from pyodata.exceptions import PyODataParserError, PyODataModelError from pyodata.model.elements import sap_attribute_get_bool, sap_attribute_get_string, StructType, StructTypeProperty, \ - NavigationTypeProperty, Identifier, Types, EnumType, EnumMember, EntitySet, EndRole, ReferentialConstraint, \ - PrincipalRole, DependentRole, Association, AssociationSetEndRole, AssociationSet, \ - ValueHelper, ValueHelperParameter, FunctionImportParameter, \ - FunctionImport, metadata_attribute_get, EntityType, ComplexType, Annotation + Identifier, Types, EnumType, EnumMember, EntitySet, ReferentialConstraint, PrincipalRole, DependentRole, \ + ValueHelper, ValueHelperParameter, FunctionImportParameter, FunctionImport, metadata_attribute_get, EntityType, \ + ComplexType, Annotation, build_element, Association, EndRole, AssociationSetEndRole, AssociationSet def modlog(): return logging.getLogger("callbacks") -def struct_type_property_from_etree(entity_type_property_node, config: Config): +def build_struct_type_property(config: Config, entity_type_property_node): return StructTypeProperty( entity_type_property_node.get('Name'), Types.parse_type_name(entity_type_property_node.get('Type')), @@ -41,7 +40,7 @@ def struct_type_property_from_etree(entity_type_property_node, config: Config): # pylint: disable=protected-access -def struct_type_from_etree(type_node, config: Config, typ, schema=None): +def build_struct_type(config: Config, type_node, typ, schema=None): name = type_node.get('Name') base_type = type_node.get('BaseType') @@ -60,7 +59,7 @@ def struct_type_from_etree(type_node, config: Config, typ, schema=None): stype._name = name for proprty in type_node.xpath('edm:Property', namespaces=config.namespaces): - stp = StructTypeProperty.from_etree(proprty, config) + stp = build_element(StructTypeProperty, config, entity_type_property_node=proprty) if stp.name in stype._properties: raise KeyError('{0} already has property {1}'.format(stype, stp.name)) @@ -77,19 +76,19 @@ def struct_type_from_etree(type_node, config: Config, typ, schema=None): return stype -def complex_type_from_etree(etree, config: Config, schema=None): - return StructType.from_etree(etree, config, typ=ComplexType, schema=schema) +def build_complex_type(config: Config, type_node, schema=None): + return build_element(StructType, config, type_node=type_node, typ=ComplexType, schema=schema) # pylint: disable=protected-access -def entity_type_from_etree(etree, config: Config, schema=None): - etype = StructType.from_etree(etree, config, typ=EntityType, schema=schema) +def build_entity_type(config: Config, type_node, schema=None): + etype = build_element(StructType, config, type_node=type_node, typ=EntityType, schema=schema) - for proprty in etree.xpath('edm:Key/edm:PropertyRef', namespaces=config.namespaces): + for proprty in type_node.xpath('edm:Key/edm:PropertyRef', namespaces=config.namespaces): etype._key.append(etype.proprty(proprty.get('Name'))) - for proprty in etree.xpath('edm:NavigationProperty', namespaces=config.namespaces): - navp = NavigationTypeProperty.from_etree(proprty, config) + for proprty in type_node.xpath('edm:NavigationProperty', namespaces=config.namespaces): + navp = build_element('NavigationTypeProperty', config, node=proprty) if navp.name in etype._nav_properties: raise KeyError('{0} already has navigation property {1}'.format(etype, navp.name)) @@ -100,7 +99,7 @@ def entity_type_from_etree(etree, config: Config, schema=None): # pylint: disable=protected-access, too-many-locals -def enum_type_from_etree(type_node, config: Config, namespace): +def build_enum_type(config: Config, type_node, namespace): ename = type_node.get('Name') is_flags = type_node.get('IsFlags') @@ -149,7 +148,7 @@ def enum_type_from_etree(type_node, config: Config, namespace): return etype -def entity_set_from_etree(entity_set_node, config): +def build_entity_set(config, entity_set_node): name = entity_set_node.get('Name') et_info = Types.parse_type_name(entity_set_node.get('EntityType')) @@ -169,7 +168,7 @@ def entity_set_from_etree(entity_set_node, config): topable, req_filter, label) -def end_role_from_etree(end_role_node, config): +def build_end_role(config: Config, end_role_node): entity_type_info = Types.parse_type_name(end_role_node.get('Type')) multiplicity = end_role_node.get('Multiplicity') role = end_role_node.get('Role') @@ -177,7 +176,7 @@ def end_role_from_etree(end_role_node, config): return EndRole(entity_type_info, multiplicity, role) -def referential_constraint_from_etree(referential_constraint_node, config: Config): +def build_referential_constraint(config: Config, referential_constraint_node): principal = referential_constraint_node.xpath('edm:Principal', namespaces=config.namespaces) if len(principal) != 1: raise RuntimeError('Referential constraint must contain exactly one principal element') @@ -212,12 +211,12 @@ def referential_constraint_from_etree(referential_constraint_node, config: Confi # pylint: disable=protected-access -def association_from_etree(association_node, config: Config): +def build_association(config: Config, association_node): name = association_node.get('Name') association = Association(name) for end in association_node.xpath('edm:End', namespaces=config.namespaces): - end_role = EndRole.from_etree(end, config) + end_role = build_element(EndRole, config, end_role_node=end) if end_role.entity_type_info is None: raise RuntimeError('End type is not specified in the association {}'.format(name)) association._end_roles.append(end_role) @@ -232,21 +231,21 @@ def association_from_etree(association_node, config: Config): if not refer: referential_constraint = None else: - referential_constraint = ReferentialConstraint.from_etree(refer[0], config) + referential_constraint = build_element(ReferentialConstraint, config, referential_constraint_node=refer[0]) association._referential_constraint = referential_constraint return association -def association_set_end_role_from_etree(end_node, config): +def build_association_set_end_role(config: Config, end_node): role = end_node.get('Role') entity_set = end_node.get('EntitySet') return AssociationSetEndRole(role, entity_set) -def association_set_from_etree(association_set_node, config: Config): +def build_association_set(config: Config, association_set_node): end_roles = [] name = association_set_node.get('Name') association = Identifier.parse(association_set_node.get('Association')) @@ -256,12 +255,12 @@ def association_set_from_etree(association_set_node, config: Config): raise PyODataModelError('Association {} cannot have more than 2 end roles'.format(name)) for end_role in end_roles_list: - end_roles.append(AssociationSetEndRole.from_etree(end_role, config)) + end_roles.append(build_element(AssociationSetEndRole, config, end_node=end_role)) return AssociationSet(name, association.name, association.namespace, end_roles) -def external_annotation_from_etree(annotations_node, config): +def build_external_annotation(config, annotations_node): target = annotations_node.get('Target') if annotations_node.get('Qualifier'): @@ -269,23 +268,23 @@ def external_annotation_from_etree(annotations_node, config): return for annotation in annotations_node.xpath('edm:Annotation', namespaces=config.annotation_namespace): - annot = Annotation.from_etree(target, config, annotation_node=annotation) + annot = build_element(Annotation, config, target=target, annotation_node=annotation) if annot is None: continue yield annot -def annotation_from_etree(target, config, annotation_node): +def build_annotation(config, target, annotation_node): term = annotation_node.get('Term') if term in config.sap_annotation_value_list: - return ValueHelper.from_etree(target, config, annotation_node=annotation_node) + return build_element(ValueHelper, config, target=target, annotation_node=annotation_node) modlog().warning('Unsupported Annotation( %s )', term) return None -def value_helper_from_etree(target, config, annotation_node): +def build_value_helper(config, target, annotation_node): label = None collection_path = None search_supported = False @@ -306,14 +305,14 @@ def value_helper_from_etree(target, config, annotation_node): if params_node is not None: for prm in params_node.xpath('edm:Collection/edm:Record', namespaces=config.annotation_namespace): - param = ValueHelperParameter.from_etree(prm, config) + param = build_element(ValueHelperParameter, config, value_help_parameter_node=prm) param.value_helper = value_helper value_helper._parameters.append(param) return value_helper -def value_helper_parameter_from_etree(value_help_parameter_node, config): +def build_value_helper_parameter(config, value_help_parameter_node): typ = value_help_parameter_node.get('Type') direction = config.sap_value_helper_directions[typ] local_prop_name = None @@ -329,7 +328,7 @@ def value_helper_parameter_from_etree(value_help_parameter_node, config): # pylint: disable=too-many-locals -def function_import_from_etree(function_import_node, config: Config): +def build_function_import(config: Config, function_import_node): name = function_import_node.get('Name') entity_set = function_import_node.get('EntitySet') http_method = metadata_attribute_get(function_import_node, 'HttpMethod') diff --git a/pyodata/model/builder.py b/pyodata/model/builder.py index 304a31c6..5ef72eb3 100644 --- a/pyodata/model/builder.py +++ b/pyodata/model/builder.py @@ -6,7 +6,7 @@ from pyodata.config import Config from pyodata.exceptions import PyODataParserError -from pyodata.model.elements import ValueHelperParameter, Schema +from pyodata.model.elements import ValueHelperParameter, Schema, build_element import pyodata.v2 as v2 @@ -105,7 +105,7 @@ def build(self): self.update_alias(self.get_aliases(xml, self._config), self._config) edm_schemas = xml.xpath('/edmx:Edmx/edmx:DataServices/edm:Schema', namespaces=self._config.namespaces) - return Schema.from_etree(edm_schemas, self._config) + return build_element(Schema, self._config, schema_nodes=edm_schemas) @staticmethod def get_aliases(edmx, config: Config): diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index bad6b5c6..543cc245 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -4,6 +4,7 @@ import itertools import logging from enum import Enum +from typing import Union from pyodata.config import Config from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError @@ -19,19 +20,33 @@ def modlog(): return logging.getLogger("Elements") -class FromEtreeMixin: - @classmethod - def from_etree(cls, etree, config: Config, **kwargs): - callbacks = config.odata_version.from_etree_callbacks() - if cls in callbacks: - callback = callbacks[cls] - else: - raise PyODataParserError(f'{cls.__name__} is unsupported in {config.odata_version.__name__}') +def build_element(element_name: Union[str, type], config: Config, **kwargs): + """ + This function is responsible for resolving which implementation is to be called for parsing EDM element. It's a + primitive implementation of dynamic dispatch, thus there exist table where all supported elements are assigned + parsing function. When elements class or element name is passed we search this table. If key exists we call the + corresponding function with kwargs arguments, otherwise we raise an exception. + + Important to note is that although elements among version, can have the same name their properties can differ + significantly thus class representing ElementX in V2 is not necessarily equal to ElementX in V4. + + :param element_name: Passing class is preferred as it does not add 'magic' strings to our code but if you + can't import the class of the element pass the class name instead. + :param config: Config + :param kwargs: Any arguments that are to be passed to the build function e. g. etree, schema... + + :return: Object + """ + + if not isinstance(element_name, str): + element_name = element_name.__name__ - if kwargs: - return callback(etree, config, **kwargs) + callbacks = config.odata_version.build_functions() + for clb in callbacks: + if element_name == clb.__name__: + return callbacks[clb](config, **kwargs) - return callback(etree, config) + raise PyODataParserError(f'{element_name} is unsupported in {config.odata_version.__name__}') class NullAssociation: @@ -277,7 +292,7 @@ def _check_scale_value(self): .format(self._scale, self._precision)) -class Schema(FromEtreeMixin): +class Schema: class Declaration: def __init__(self, namespace): super(Schema.Declaration, self).__init__() @@ -567,7 +582,7 @@ def check_role_property_names(self, role, entity_type_name, namespace): raise PyODataModelError('Property {} does not exist in {}'.format(proprty, entity_type.name)) -class StructType(FromEtreeMixin, Typ): +class StructType(Typ): def __init__(self, name, label, is_value_list): super(StructType, self).__init__(name, None, EdmStructTypTraits(self), Typ.Kinds.Complex) @@ -635,7 +650,7 @@ def parent(self): return self._parent -class EnumType(FromEtreeMixin, Identifier): +class EnumType(Identifier): def __init__(self, name, is_flags, underlying_type, namespace): super(EnumType, self).__init__(name) self._member = list() @@ -702,7 +717,7 @@ def nav_proprty(self, property_name): return self._nav_properties[property_name] -class EntitySet(FromEtreeMixin, Identifier): +class EntitySet(Identifier): def __init__(self, name, entity_type_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, topable, req_filter, label): super(EntitySet, self).__init__(name) @@ -779,7 +794,7 @@ def label(self): return self._label -class StructTypeProperty(FromEtreeMixin, VariableDeclaration): +class StructTypeProperty(VariableDeclaration): """Property of structure types (Entity/Complex type) Type of the property can be: @@ -906,7 +921,7 @@ def value_helper(self, value): self._value_helper = value -class NavigationTypeProperty(FromEtreeMixin, VariableDeclaration): +class NavigationTypeProperty(VariableDeclaration): """Defines a navigation property, which provides a reference to the other end of an association Unlike properties defined with the Property element, navigation properties do not define the @@ -962,7 +977,7 @@ def typ(self): return self.to_role.entity_type -class EndRole(FromEtreeMixin): +class EndRole: MULTIPLICITY_ONE = '1' MULTIPLICITY_ZERO_OR_ONE = '0..1' MULTIPLICITY_ZERO_OR_MORE = '*' @@ -1030,7 +1045,7 @@ class DependentRole(ReferentialConstraintRole): pass -class ReferentialConstraint(FromEtreeMixin): +class ReferentialConstraint(): def __init__(self, principal, dependent): self._principal = principal self._dependent = dependent @@ -1044,7 +1059,7 @@ def dependent(self): return self._dependent -class Association(FromEtreeMixin): +class Association: """Defines a relationship between two entity types. An association must specify the entity types that are involved in @@ -1082,7 +1097,7 @@ def referential_constraint(self): return self._referential_constraint -class AssociationSetEndRole(FromEtreeMixin): +class AssociationSetEndRole: def __init__(self, role, entity_set_name): self._role = role self._entity_set_name = entity_set_name @@ -1115,7 +1130,7 @@ def entity_set(self, value): self._entity_set = value -class AssociationSet(FromEtreeMixin): +class AssociationSet: def __init__(self, name, association_type_name, association_type_namespace, end_roles): self._name = name self._association_type_name = association_type_name @@ -1165,7 +1180,7 @@ def association_type(self, value): self._association_type = value -class Annotation(FromEtreeMixin): +class Annotation(): Kinds = Enum('Kinds', 'ValueHelper') def __init__(self, kind, target, qualifier=None): @@ -1195,7 +1210,8 @@ def kind(self): return self._kind -class ExternalAnnotation(FromEtreeMixin): +# pylint: disable=too-few-public-methods +class ExternalAnnotation(): pass @@ -1299,7 +1315,7 @@ def list_property_param(self, name): raise KeyError('{0} has no list property {1}'.format(self, name)) -class ValueHelperParameter(FromEtreeMixin): +class ValueHelperParameter(): Direction = Enum('Direction', 'In InOut Out DisplayOnly FilterOnly') def __init__(self, direction, local_property_name, list_property_name): @@ -1366,7 +1382,7 @@ def list_property(self, value): self._list_property = value -class FunctionImport(FromEtreeMixin, Identifier): +class FunctionImport(Identifier): def __init__(self, name, return_type_info, entity_set, parameters, http_method='GET'): super(FunctionImport, self).__init__(name) diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index cf4098a3..cfc25820 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -1,6 +1,5 @@ """ This module represents implementation of ODATA V2 """ -import itertools import logging from typing import List @@ -8,23 +7,14 @@ from pyodata.model.type_traits import EdmBooleanTypTraits, EdmPrefixedTypTraits, EdmIntTypTraits, \ EdmLongIntTypTraits, EdmStringTypTraits -from pyodata.policies import ParserError -from pyodata.config import ODATAVersion, Config -from pyodata.exceptions import PyODataParserError, PyODataModelError +from pyodata.config import ODATAVersion -from pyodata.model.elements import StructTypeProperty, StructType, NavigationTypeProperty, ComplexType, EntityType, \ - EnumType, EntitySet, EndRole, ReferentialConstraint, Association, AssociationSetEndRole, AssociationSet, \ - ExternalAnnotation, Annotation, ValueHelper, ValueHelperParameter, FunctionImport, Schema, NullType, Typ, \ - NullAssociation +from pyodata.model.elements import StructTypeProperty, StructType, ComplexType, EntityType, \ + EnumType, EntitySet, ReferentialConstraint, ExternalAnnotation, Annotation, ValueHelper, ValueHelperParameter, \ + FunctionImport, Schema, Typ, NavigationTypeProperty, EndRole, Association, AssociationSetEndRole, AssociationSet -from pyodata.model.from_etree_callbacks import struct_type_property_from_etree, struct_type_from_etree, \ - entity_type_from_etree, enum_type_from_etree, entity_set_from_etree, end_role_from_etree, \ - referential_constraint_from_etree, association_from_etree, association_set_end_role_from_etree, \ - association_set_from_etree, external_annotation_from_etree, annotation_from_etree, value_helper_from_etree, \ - value_helper_parameter_from_etree, function_import_from_etree, complex_type_from_etree - - -from pyodata.v2.from_etree_callbacks import navigation_type_property_from_etree +import pyodata.v2.build_functions as build_functions_v2 +import pyodata.model.build_functions as build_functions def modlog(): @@ -36,26 +26,26 @@ class ODataV2(ODATAVersion): """ Definition of OData V2 """ @staticmethod - def from_etree_callbacks(): + def build_functions(): return { - StructTypeProperty: struct_type_property_from_etree, - StructType: struct_type_from_etree, - NavigationTypeProperty: navigation_type_property_from_etree, - ComplexType: complex_type_from_etree, - EntityType: entity_type_from_etree, - EnumType: enum_type_from_etree, - EntitySet: entity_set_from_etree, - EndRole: end_role_from_etree, - ReferentialConstraint: referential_constraint_from_etree, - Association: association_from_etree, - AssociationSetEndRole: association_set_end_role_from_etree, - AssociationSet: association_set_from_etree, - ExternalAnnotation: external_annotation_from_etree, - Annotation: annotation_from_etree, - ValueHelper: value_helper_from_etree, - ValueHelperParameter: value_helper_parameter_from_etree, - FunctionImport: function_import_from_etree, - Schema: ODataV2.schema_from_etree + StructTypeProperty: build_functions.build_struct_type_property, + StructType: build_functions.build_struct_type, + NavigationTypeProperty: build_functions_v2.build_navigation_type_property, + ComplexType: build_functions.build_complex_type, + EntityType: build_functions.build_entity_type, + EnumType: build_functions.build_enum_type, + EntitySet: build_functions.build_entity_set, + EndRole: build_functions.build_end_role, + ReferentialConstraint: build_functions.build_referential_constraint, + Association: build_functions.build_association, + AssociationSetEndRole: build_functions.build_association_set_end_role, + AssociationSet: build_functions.build_association_set, + ExternalAnnotation: build_functions.build_external_annotation, + Annotation: build_functions.build_annotation, + ValueHelper: build_functions.build_value_helper, + ValueHelperParameter: build_functions.build_value_helper_parameter, + FunctionImport: build_functions.build_function_import, + Schema: build_functions_v2.build_schema } @staticmethod @@ -78,226 +68,3 @@ def primitive_types() -> List[Typ]: Typ('Edm.Time', 'time\'PT00H00M\''), Typ('Edm.DateTimeOffset', 'datetimeoffset\'0000-00-00T00:00:00\'') ] - - # pylint: disable=too-many-locals,too-many-branches,too-many-statements, protected-access,missing-docstring - @staticmethod - def schema_from_etree(schema_nodes, config: Config): - schema = Schema(config) - - # Parse Schema nodes by parts to get over the problem of not-yet known - # entity types referenced by entity sets, function imports and - # annotations. - - # First, process EnumType, EntityType and ComplexType nodes. They have almost no dependencies on other elements. - for schema_node in schema_nodes: - namespace = schema_node.get('Namespace') - decl = Schema.Declaration(namespace) - schema._decls[namespace] = decl - - for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces): - try: - etype = EnumType.from_etree(enum_type, config, namespace=namespace) - except (PyODataParserError, AttributeError) as ex: - config.err_policy(ParserError.ENUM_TYPE).resolve(ex) - etype = NullType(enum_type.get('Name')) - - decl.add_enum_type(etype) - - for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): - try: - ctype = ComplexType.from_etree(complex_type, config) - except (KeyError, AttributeError) as ex: - config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) - ctype = NullType(complex_type.get('Name')) - - decl.add_complex_type(ctype) - - for entity_type in schema_node.xpath('edm:EntityType', namespaces=config.namespaces): - try: - etype = EntityType.from_etree(entity_type, config) - except (KeyError, AttributeError) as ex: - config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) - etype = NullType(entity_type.get('Name')) - - decl.add_entity_type(etype) - - # resolve types of properties - for stype in itertools.chain(schema.entity_types, schema.complex_types): - if isinstance(stype, NullType): - continue - - if stype.kind == Typ.Kinds.Complex: - # skip collections (no need to assign any types since type of collection - # items is resolved separately - if stype.is_collection: - continue - - for prop in stype.proprties(): - try: - prop.typ = schema.get_type(prop.type_info) - except PyODataModelError as ex: - config.err_policy(ParserError.PROPERTY).resolve(ex) - prop.typ = NullType(prop.type_info.name) - - # pylint: disable=too-many-nested-blocks - # Then, process Associations nodes because they refer EntityTypes and - # they are referenced by AssociationSets. - for schema_node in schema_nodes: - namespace = schema_node.get('Namespace') - decl = schema._decls[namespace] - - for association in schema_node.xpath('edm:Association', namespaces=config.namespaces): - assoc = Association.from_etree(association, config) - try: - for end_role in assoc.end_roles: - try: - # search and assign entity type (it must exist) - if end_role.entity_type_info.namespace is None: - end_role.entity_type_info.namespace = namespace - - etype = schema.entity_type(end_role.entity_type_info.name, - end_role.entity_type_info.namespace) - - end_role.entity_type = etype - except KeyError: - raise PyODataModelError( - f'EntityType {end_role.entity_type_info.name} does not exist in Schema ' - f'Namespace {end_role.entity_type_info.namespace}') - - if assoc.referential_constraint is not None: - role_names = [end_role.role for end_role in assoc.end_roles] - principal_role = assoc.referential_constraint.principal - - # Check if the role was defined in the current association - if principal_role.name not in role_names: - raise RuntimeError( - 'Role {} was not defined in association {}'.format(principal_role.name, assoc.name)) - - # Check if principal role properties exist - role_name = principal_role.name - entity_type_name = assoc.end_by_role(role_name).entity_type_name - schema.check_role_property_names(principal_role, entity_type_name, namespace) - - dependent_role = assoc.referential_constraint.dependent - - # Check if the role was defined in the current association - if dependent_role.name not in role_names: - raise RuntimeError( - 'Role {} was not defined in association {}'.format(dependent_role.name, assoc.name)) - - # Check if dependent role properties exist - role_name = dependent_role.name - entity_type_name = assoc.end_by_role(role_name).entity_type_name - schema.check_role_property_names(dependent_role, entity_type_name, namespace) - except (PyODataModelError, RuntimeError) as ex: - config.err_policy(ParserError.ASSOCIATION).resolve(ex) - decl.associations[assoc.name] = NullAssociation(assoc.name) - else: - decl.associations[assoc.name] = assoc - - # resolve navigation properties - for stype in schema.entity_types: - # skip null type - if isinstance(stype, NullType): - continue - - # skip collections - if stype.is_collection: - continue - - for nav_prop in stype.nav_proprties: - try: - assoc = schema.association(nav_prop.association_info.name, nav_prop.association_info.namespace) - nav_prop.association = assoc - except KeyError as ex: - config.err_policy(ParserError.ASSOCIATION).resolve(ex) - nav_prop.association = NullAssociation(nav_prop.association_info.name) - - # Then, process EntitySet, FunctionImport and AssociationSet nodes. - for schema_node in schema_nodes: - namespace = schema_node.get('Namespace') - decl = schema._decls[namespace] - - for entity_set in schema_node.xpath('edm:EntityContainer/edm:EntitySet', namespaces=config.namespaces): - eset = EntitySet.from_etree(entity_set, config) - eset.entity_type = schema.entity_type(eset.entity_type_info[1], namespace=eset.entity_type_info[0]) - decl.entity_sets[eset.name] = eset - - for function_import in schema_node.xpath('edm:EntityContainer/edm:FunctionImport', - namespaces=config.namespaces): - efn = FunctionImport.from_etree(function_import, config) - - # complete type information for return type and parameters - if efn.return_type_info is not None: - efn.return_type = schema.get_type(efn.return_type_info) - for param in efn.parameters: - param.typ = schema.get_type(param.type_info) - decl.function_imports[efn.name] = efn - - for association_set in schema_node.xpath('edm:EntityContainer/edm:AssociationSet', - namespaces=config.namespaces): - assoc_set = AssociationSet.from_etree(association_set, config) - try: - try: - assoc_set.association_type = schema.association(assoc_set.association_type_name, - assoc_set.association_type_namespace) - except KeyError: - raise PyODataModelError( - 'Association {} does not exist in namespace {}' - .format(assoc_set.association_type_name, assoc_set.association_type_namespace)) - - for end in assoc_set.end_roles: - # Check if an entity set exists in the current scheme - # and add a reference to the corresponding entity set - try: - entity_set = schema.entity_set(end.entity_set_name, namespace) - end.entity_set = entity_set - except KeyError: - raise PyODataModelError('EntitySet {} does not exist in Schema Namespace {}' - .format(end.entity_set_name, namespace)) - # Check if role is defined in Association - if assoc_set.association_type.end_by_role(end.role) is None: - raise PyODataModelError('Role {} is not defined in association {}' - .format(end.role, assoc_set.association_type_name)) - except (PyODataModelError, KeyError) as ex: - config.err_policy(ParserError.ASSOCIATION).resolve(ex) - decl.association_sets[assoc_set.name] = NullAssociation(assoc_set.name) - else: - decl.association_sets[assoc_set.name] = assoc_set - - # pylint: disable=too-many-nested-blocks - # Finally, process Annotation nodes when all Scheme nodes are completely processed. - for schema_node in schema_nodes: - for annotation_group in schema_node.xpath('edm:Annotations', namespaces=config.annotation_namespace): - etree = ExternalAnnotation.from_etree(annotation_group, config) - for annotation in etree: - if not annotation.element_namespace != schema.namespaces: - modlog().warning('%s not in the namespaces %s', annotation, ','.join(schema.namespaces)) - continue - - try: - if annotation.kind == Annotation.Kinds.ValueHelper: - try: - annotation.entity_set = schema.entity_set( - annotation.collection_path, namespace=annotation.element_namespace) - except KeyError: - raise RuntimeError(f'Entity Set {annotation.collection_path} ' - f'for {annotation} does not exist') - - try: - vh_type = schema.typ(annotation.proprty_entity_type_name, - namespace=annotation.element_namespace) - except KeyError: - raise RuntimeError(f'Target Type {annotation.proprty_entity_type_name} ' - f'of {annotation} does not exist') - - try: - target_proprty = vh_type.proprty(annotation.proprty_name) - except KeyError: - raise RuntimeError(f'Target Property {annotation.proprty_name} ' - f'of {vh_type} as defined in {annotation} does not exist') - annotation.proprty = target_proprty - target_proprty.value_helper = annotation - except (RuntimeError, PyODataModelError) as ex: - config.err_policy(ParserError.ANNOTATION).resolve(ex) - return schema diff --git a/pyodata/v2/build_functions.py b/pyodata/v2/build_functions.py new file mode 100644 index 00000000..dc31179d --- /dev/null +++ b/pyodata/v2/build_functions.py @@ -0,0 +1,247 @@ +""" Repository of build functions specific to the ODATA V2""" + +# pylint: disable=unused-argument, missing-docstring +# All methods by design of 'build_element' accept config, but no all have to use it + +import itertools +import logging + +from pyodata.config import Config +from pyodata.exceptions import PyODataParserError, PyODataModelError +from pyodata.model.elements import EntityType, ComplexType, Schema, EnumType, NullType, build_element, \ + NullAssociation, EntitySet, FunctionImport, AssociationSet, ExternalAnnotation, Annotation, Association, Typ, \ + NavigationTypeProperty, Identifier +from pyodata.policies import ParserError + + +def modlog(): + """ Logging function for debugging.""" + return logging.getLogger("v2_build_functions") + + +def build_navigation_type_property(config: Config, node): + return NavigationTypeProperty( + node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship'))) + + +# pylint: disable=protected-access,too-many-locals, too-many-branches,too-many-statements +# While building schema it is necessary to set few attributes which in the rest of the application should remain +# constant. As for now, splitting build_schema into sub-functions would not add any benefits. +def build_schema(config: Config, schema_nodes): + schema = Schema(config) + + # Parse Schema nodes by parts to get over the problem of not-yet known + # entity types referenced by entity sets, function imports and + # annotations. + + # First, process EnumType, EntityType and ComplexType nodes. They have almost no dependencies on other elements. + for schema_node in schema_nodes: + namespace = schema_node.get('Namespace') + decl = Schema.Declaration(namespace) + schema._decls[namespace] = decl + + for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces): + try: + etype = build_element(EnumType, config, type_node=enum_type, namespace=namespace) + except (PyODataParserError, AttributeError) as ex: + config.err_policy(ParserError.ENUM_TYPE).resolve(ex) + etype = NullType(enum_type.get('Name')) + + decl.add_enum_type(etype) + + for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): + try: + ctype = build_element(ComplexType, config, type_node=complex_type) + except (KeyError, AttributeError) as ex: + config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) + ctype = NullType(complex_type.get('Name')) + + decl.add_complex_type(ctype) + + for entity_type in schema_node.xpath('edm:EntityType', namespaces=config.namespaces): + try: + etype = build_element(EntityType, config, type_node=entity_type) + except (KeyError, AttributeError) as ex: + config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) + etype = NullType(entity_type.get('Name')) + + decl.add_entity_type(etype) + + # resolve types of properties + for stype in itertools.chain(schema.entity_types, schema.complex_types): + if isinstance(stype, NullType): + continue + + if stype.kind == Typ.Kinds.Complex: + # skip collections (no need to assign any types since type of collection + # items is resolved separately + if stype.is_collection: + continue + + for prop in stype.proprties(): + try: + prop.typ = schema.get_type(prop.type_info) + except PyODataModelError as ex: + config.err_policy(ParserError.PROPERTY).resolve(ex) + prop.typ = NullType(prop.type_info.name) + + # pylint: disable=too-many-nested-blocks + # Then, process Associations nodes because they refer EntityTypes and + # they are referenced by AssociationSets. + for schema_node in schema_nodes: + namespace = schema_node.get('Namespace') + decl = schema._decls[namespace] + + for association in schema_node.xpath('edm:Association', namespaces=config.namespaces): + assoc = build_element(Association, config, association_node=association) + try: + for end_role in assoc.end_roles: + try: + # search and assign entity type (it must exist) + if end_role.entity_type_info.namespace is None: + end_role.entity_type_info.namespace = namespace + + etype = schema.entity_type(end_role.entity_type_info.name, end_role.entity_type_info.namespace) + + end_role.entity_type = etype + except KeyError: + raise PyODataModelError( + f'EntityType {end_role.entity_type_info.name} does not exist in Schema ' + f'Namespace {end_role.entity_type_info.namespace}') + + if assoc.referential_constraint is not None: + role_names = [end_role.role for end_role in assoc.end_roles] + principal_role = assoc.referential_constraint.principal + + # Check if the role was defined in the current association + if principal_role.name not in role_names: + raise RuntimeError( + 'Role {} was not defined in association {}'.format(principal_role.name, assoc.name)) + + # Check if principal role properties exist + role_name = principal_role.name + entity_type_name = assoc.end_by_role(role_name).entity_type_name + schema.check_role_property_names(principal_role, entity_type_name, namespace) + + dependent_role = assoc.referential_constraint.dependent + + # Check if the role was defined in the current association + if dependent_role.name not in role_names: + raise RuntimeError( + 'Role {} was not defined in association {}'.format(dependent_role.name, assoc.name)) + + # Check if dependent role properties exist + role_name = dependent_role.name + entity_type_name = assoc.end_by_role(role_name).entity_type_name + schema.check_role_property_names(dependent_role, entity_type_name, namespace) + except (PyODataModelError, RuntimeError) as ex: + config.err_policy(ParserError.ASSOCIATION).resolve(ex) + decl.associations[assoc.name] = NullAssociation(assoc.name) + else: + decl.associations[assoc.name] = assoc + + # resolve navigation properties + for stype in schema.entity_types: + # skip null type + if isinstance(stype, NullType): + continue + + # skip collections + if stype.is_collection: + continue + + for nav_prop in stype.nav_proprties: + try: + assoc = schema.association(nav_prop.association_info.name, nav_prop.association_info.namespace) + nav_prop.association = assoc + except KeyError as ex: + config.err_policy(ParserError.ASSOCIATION).resolve(ex) + nav_prop.association = NullAssociation(nav_prop.association_info.name) + + # Then, process EntitySet, FunctionImport and AssociationSet nodes. + for schema_node in schema_nodes: + namespace = schema_node.get('Namespace') + decl = schema._decls[namespace] + + for entity_set in schema_node.xpath('edm:EntityContainer/edm:EntitySet', namespaces=config.namespaces): + eset = build_element(EntitySet, config, entity_set_node=entity_set) + eset.entity_type = schema.entity_type(eset.entity_type_info[1], namespace=eset.entity_type_info[0]) + decl.entity_sets[eset.name] = eset + + for function_import in schema_node.xpath('edm:EntityContainer/edm:FunctionImport', + namespaces=config.namespaces): + efn = build_element(FunctionImport, config, function_import_node=function_import) + + # complete type information for return type and parameters + if efn.return_type_info is not None: + efn.return_type = schema.get_type(efn.return_type_info) + for param in efn.parameters: + param.typ = schema.get_type(param.type_info) + decl.function_imports[efn.name] = efn + + for association_set in schema_node.xpath('edm:EntityContainer/edm:AssociationSet', + namespaces=config.namespaces): + assoc_set = build_element(AssociationSet, config, association_set_node=association_set) + try: + try: + assoc_set.association_type = schema.association(assoc_set.association_type_name, + assoc_set.association_type_namespace) + except KeyError: + raise PyODataModelError(f'Association {assoc_set.association_type_name} does not exist in namespace' + f' {assoc_set.association_type_namespace}') + + for end in assoc_set.end_roles: + # Check if an entity set exists in the current scheme + # and add a reference to the corresponding entity set + try: + entity_set = schema.entity_set(end.entity_set_name, namespace) + end.entity_set = entity_set + except KeyError: + raise PyODataModelError('EntitySet {} does not exist in Schema Namespace {}' + .format(end.entity_set_name, namespace)) + # Check if role is defined in Association + if assoc_set.association_type.end_by_role(end.role) is None: + raise PyODataModelError('Role {} is not defined in association {}' + .format(end.role, assoc_set.association_type_name)) + except (PyODataModelError, KeyError) as ex: + config.err_policy(ParserError.ASSOCIATION).resolve(ex) + decl.association_sets[assoc_set.name] = NullAssociation(assoc_set.name) + else: + decl.association_sets[assoc_set.name] = assoc_set + + # pylint: disable=too-many-nested-blocks + # Finally, process Annotation nodes when all Scheme nodes are completely processed. + for schema_node in schema_nodes: + for annotation_group in schema_node.xpath('edm:Annotations', namespaces=config.annotation_namespace): + etree = build_element(ExternalAnnotation, config, annotations_node=annotation_group) + for annotation in etree: + if not annotation.element_namespace != schema.namespaces: + modlog().warning('%s not in the namespaces %s', annotation, ','.join(schema.namespaces)) + continue + + try: + if annotation.kind == Annotation.Kinds.ValueHelper: + try: + annotation.entity_set = schema.entity_set( + annotation.collection_path, namespace=annotation.element_namespace) + except KeyError: + raise RuntimeError(f'Entity Set {annotation.collection_path} ' + f'for {annotation} does not exist') + + try: + vh_type = schema.typ(annotation.proprty_entity_type_name, + namespace=annotation.element_namespace) + except KeyError: + raise RuntimeError(f'Target Type {annotation.proprty_entity_type_name} ' + f'of {annotation} does not exist') + + try: + target_proprty = vh_type.proprty(annotation.proprty_name) + except KeyError: + raise RuntimeError(f'Target Property {annotation.proprty_name} ' + f'of {vh_type} as defined in {annotation} does not exist') + annotation.proprty = target_proprty + target_proprty.value_helper = annotation + except (RuntimeError, PyODataModelError) as ex: + config.err_policy(ParserError.ANNOTATION).resolve(ex) + return schema diff --git a/pyodata/v2/from_etree_callbacks.py b/pyodata/v2/from_etree_callbacks.py deleted file mode 100644 index 1090a364..00000000 --- a/pyodata/v2/from_etree_callbacks.py +++ /dev/null @@ -1,9 +0,0 @@ -# pylint: disable=missing-docstring,invalid-name,unused-argument - -from pyodata.model.elements import NavigationTypeProperty, Identifier -from pyodata.config import Config - - -def navigation_type_property_from_etree(node, config: Config): - return NavigationTypeProperty( - node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship'))) diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py index 0cc396a6..f9bdf746 100644 --- a/pyodata/v4/__init__.py +++ b/pyodata/v4/__init__.py @@ -2,29 +2,29 @@ from typing import List -from pyodata.model.from_etree_callbacks import enum_type_from_etree, struct_type_property_from_etree, \ - struct_type_from_etree, complex_type_from_etree from pyodata.config import ODATAVersion from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits from pyodata.model.elements import Typ, Schema, EnumType, ComplexType, StructType, StructTypeProperty from pyodata.v4.type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration -from pyodata.v4.from_etree_callbacks import schema_from_etree + +import pyodata.v4.build_functions as build_functions_v4 +import pyodata.model.build_functions as build_functions class ODataV4(ODATAVersion): """ Definition of OData V4 """ @staticmethod - def from_etree_callbacks(): + def build_functions(): return { - StructTypeProperty: struct_type_property_from_etree, - StructType: struct_type_from_etree, + StructTypeProperty: build_functions.build_struct_type_property, + StructType: build_functions.build_struct_type, # NavigationTypeProperty: navigation_type_property_from_etree, - EnumType: enum_type_from_etree, - ComplexType: complex_type_from_etree, - Schema: schema_from_etree, + EnumType: build_functions.build_enum_type, + ComplexType: build_functions.build_complex_type, + Schema: build_functions_v4.build_schema, } @staticmethod diff --git a/pyodata/v4/from_etree_callbacks.py b/pyodata/v4/build_functions.py similarity index 76% rename from pyodata/v4/from_etree_callbacks.py rename to pyodata/v4/build_functions.py index 7bae9cfe..17df7eb6 100644 --- a/pyodata/v4/from_etree_callbacks.py +++ b/pyodata/v4/build_functions.py @@ -1,11 +1,17 @@ -# pylint: disable=missing-docstring,invalid-name,unused-argument,protected-access +""" Repository of build functions specific to the ODATA V4""" + +# pylint: disable=missing-docstring + from pyodata.config import Config from pyodata.exceptions import PyODataParserError -from pyodata.model.elements import ComplexType, Schema, EnumType, NullType +from pyodata.model.elements import ComplexType, Schema, EnumType, NullType, build_element from pyodata.policies import ParserError -def schema_from_etree(schema_nodes, config: Config): +# pylint: disable=protected-access +# While building schema it is necessary to set few attributes which in the rest of the application should remain +# constant. +def build_schema(config: Config, schema_nodes): schema = Schema(config) # Parse Schema nodes by parts to get over the problem of not-yet known @@ -21,7 +27,7 @@ def schema_from_etree(schema_nodes, config: Config): for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces): try: - etype = EnumType.from_etree(enum_type, config, namespace=namespace) + etype = build_element(EnumType, config, type_node=enum_type, namespace=namespace) except (PyODataParserError, AttributeError) as ex: config.err_policy(ParserError.ENUM_TYPE).resolve(ex) etype = NullType(enum_type.get('Name')) @@ -30,7 +36,7 @@ def schema_from_etree(schema_nodes, config: Config): for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): try: - ctype = ComplexType.from_etree(complex_type, config, schema=schema) + ctype = build_element(ComplexType, config, type_node=complex_type, schema=schema) except (KeyError, AttributeError) as ex: config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) ctype = NullType(complex_type.get('Name')) diff --git a/tests/test_model.py b/tests/test_model.py index c3ef11b7..6672c48b 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -13,7 +13,7 @@ def test_from_etree_mixin(metadata_v2): class EmptyODATA(ODATAVersion): @staticmethod - def from_etree_callbacks(): + def build_functions(): return {} config = Config(EmptyODATA) @@ -48,7 +48,7 @@ def test_odata_version_statelessness(): class EmptyODATA(ODATAVersion): @staticmethod - def from_etree_callbacks(): + def build_functions(): return {} @staticmethod diff --git a/tests/test_model_v2.py b/tests/test_model_v2.py index b3af993a..fbd6641b 100644 --- a/tests/test_model_v2.py +++ b/tests/test_model_v2.py @@ -2,14 +2,14 @@ # pylint: disable=line-too-long,too-many-locals,too-many-statements,invalid-name, too-many-lines, no-name-in-module, expression-not-assigned, pointless-statement import os from datetime import datetime, timezone -from unittest.mock import patch +from unittest.mock import patch, MagicMock import pytest from tests.conftest import assert_logging_policy from pyodata.config import Config from pyodata.model.builder import MetadataBuilder -from pyodata.model.elements import Typ, Types, EntityType, TypeInfo, NullType, NullAssociation, EndRole, \ - AssociationSetEndRole, Schema, StructTypeProperty, AssociationSet, Association +from pyodata.model.elements import Typ, Types, EntityType, TypeInfo, NullType, NullAssociation, Schema, \ + StructTypeProperty, EndRole, AssociationSetEndRole, AssociationSet, Association from pyodata.model.type_traits import EdmStructTypeSerializer from pyodata.policies import ParserError, PolicyWarning, PolicyIgnore, PolicyFatal from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError @@ -1069,8 +1069,8 @@ def test_missing_schema(xml_builder_factory): assert str(ex) == 'Metadata document is missing the element Schema' -@patch.object(Schema, 'from_etree') -def test_namespace_whitelist(mock_from_etree, xml_builder_factory): +@patch('pyodata.model.builder.build_element', return_value='Mocked') +def test_namespace_whitelist(mock_build_element: MagicMock, xml_builder_factory): """Test correct handling of whitelisted namespaces""" xml_builder = xml_builder_factory() @@ -1079,13 +1079,11 @@ def test_namespace_whitelist(mock_from_etree, xml_builder_factory): xml_builder.add_schema('', '') xml = xml_builder.serialize() - MetadataBuilder(xml).build() - assert Schema.from_etree is mock_from_etree - mock_from_etree.assert_called_once() + assert MetadataBuilder(xml).build() == 'Mocked' -@patch.object(Schema, 'from_etree') -def test_unsupported_edmx_n(mock_from_etree, xml_builder_factory): +@patch('pyodata.model.builder.build_element', return_value='Mocked') +def test_unsupported_edmx_n(mock_build_element, xml_builder_factory): """Test correct handling of non-whitelisted Edmx namespaces""" xml_builder = xml_builder_factory() @@ -1094,7 +1092,7 @@ def test_unsupported_edmx_n(mock_from_etree, xml_builder_factory): xml_builder.add_schema('', '') xml = xml_builder.serialize() - MetadataBuilder( + schema = MetadataBuilder( xml, config=Config( ODataV2, @@ -1102,19 +1100,18 @@ def test_unsupported_edmx_n(mock_from_etree, xml_builder_factory): ) ).build() - assert Schema.from_etree is mock_from_etree - mock_from_etree.assert_called_once() + assert schema == 'Mocked' try: MetadataBuilder(xml).build() except PyODataParserError as ex: assert str(ex) == f'Unsupported Edmx namespace - {edmx}' - mock_from_etree.assert_called_once() + mock_build_element.assert_called_once() -@patch.object(Schema, 'from_etree') -def test_unsupported_schema_n(mock_from_etree, xml_builder_factory): +@patch('pyodata.model.builder.build_element', return_value='Mocked') +def test_unsupported_schema_n(mock_build_element, xml_builder_factory): """Test correct handling of non-whitelisted Schema namespaces""" xml_builder = xml_builder_factory() @@ -1123,7 +1120,7 @@ def test_unsupported_schema_n(mock_from_etree, xml_builder_factory): xml_builder.add_schema('', '') xml = xml_builder.serialize() - MetadataBuilder( + schema = MetadataBuilder( xml, config=Config( ODataV2, @@ -1131,19 +1128,17 @@ def test_unsupported_schema_n(mock_from_etree, xml_builder_factory): ) ).build() - assert Schema.from_etree is mock_from_etree - mock_from_etree.assert_called_once() + assert schema == 'Mocked' try: - MetadataBuilder(xml).build() except PyODataParserError as ex: assert str(ex) == f'Unsupported Schema namespace - {edm}' - mock_from_etree.assert_called_once() + mock_build_element.assert_called_once() -@patch.object(Schema, 'from_etree') +@patch('pyodata.model.builder.build_element', return_value='Mocked') def test_whitelisted_edm_namespace(mock_from_etree, xml_builder_factory): """Test correct handling of whitelisted Microsoft's edm namespace""" @@ -1152,13 +1147,11 @@ def test_whitelisted_edm_namespace(mock_from_etree, xml_builder_factory): xml_builder.add_schema('', '') xml = xml_builder.serialize() - MetadataBuilder(xml).build() - assert Schema.from_etree is mock_from_etree - mock_from_etree.assert_called_once() + assert MetadataBuilder(xml).build() == 'Mocked' -@patch.object(Schema, 'from_etree') -def test_whitelisted_edm_namespace_2006_04(mock_from_etree, xml_builder_factory): +@patch('pyodata.v2.build_functions_v2.build_schema') +def test_whitelisted_edm_namespace_2006_04(mocked, xml_builder_factory): """Test correct handling of whitelisted Microsoft's edm namespace""" xml_builder = xml_builder_factory() @@ -1167,12 +1160,11 @@ def test_whitelisted_edm_namespace_2006_04(mock_from_etree, xml_builder_factory) xml = xml_builder.serialize() MetadataBuilder(xml).build() - assert Schema.from_etree is mock_from_etree - mock_from_etree.assert_called_once() + mocked.assert_called_once() -@patch.object(Schema, 'from_etree') -def test_whitelisted_edm_namespace_2007_05(mock_from_etree, xml_builder_factory): +@patch('pyodata.v2.build_functions_v2.build_schema') +def test_whitelisted_edm_namespace_2007_05(mocked, xml_builder_factory): """Test correct handling of whitelisted Microsoft's edm namespace""" xml_builder = xml_builder_factory() @@ -1181,8 +1173,7 @@ def test_whitelisted_edm_namespace_2007_05(mock_from_etree, xml_builder_factory) xml = xml_builder.serialize() MetadataBuilder(xml).build() - assert Schema.from_etree is mock_from_etree - mock_from_etree.assert_called_once() + mocked.assert_called_once() def test_enum_parsing(schema): diff --git a/tests/test_model_v4.py b/tests/test_model_v4.py index a0d85381..8a3513ba 100644 --- a/tests/test_model_v4.py +++ b/tests/test_model_v4.py @@ -3,8 +3,9 @@ import geojson import pytest -from model.builder import MetadataBuilder +from pyodata.model.builder import MetadataBuilder from pyodata.exceptions import PyODataModelError +from pyodata.model.type_traits import TypTraits from pyodata.model.elements import Types from pyodata.config import Config From 03e866b88f80de13656f3474e969461834a56236 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 18 Oct 2019 15:07:36 +0200 Subject: [PATCH 06/36] Fix test_types_repository_separation test Previous test was faulty as it relied on being call before any other test initiated ODataV2.Types. --- CHANGELOG.md | 1 + tests/test_model.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e5d8f6c..b7821daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - make sure configured error policies are applied for Annotations referencing unknown type/member - Martin Miksik +- Race condition in `test_types_repository_separation` - Martin Miksik ## [1.3.0] diff --git a/tests/test_model.py b/tests/test_model.py index 6672c48b..4afadaf0 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -63,6 +63,7 @@ def primitive_types() -> List[Typ]: def test_types_repository_separation(): + ODataV2.Types = dict() class TestODATA(ODATAVersion): @staticmethod @@ -74,11 +75,12 @@ def primitive_types() -> List['Typ']: config_test = Config(TestODATA) config_v2 = Config(ODataV2) - assert TestODATA.Types is None + assert not TestODATA.Types assert TestODATA.Types == ODataV2.Types # Build type repository by initial call Types.from_name('PrimitiveType', config_test) Types.from_name('Edm.Int16', config_v2) + assert ODataV2.Types assert TestODATA.Types != ODataV2.Types \ No newline at end of file From 27302d2524887d9a52cf8edf0336e140f4e876df Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 18 Oct 2019 15:24:18 +0200 Subject: [PATCH 07/36] Add support for NavigationTypeProperty in V4 Functions and elements removed/replaced in V4 corresponding with NavigationTypeProperty were moved to v2 module. --- CHANGELOG.md | 1 + pyodata/model/build_functions.py | 101 +--------- pyodata/model/elements.py | 314 ------------------------------ pyodata/policies.py | 2 + pyodata/v2/__init__.py | 17 +- pyodata/v2/build_functions.py | 110 ++++++++++- pyodata/v2/elements.py | 324 +++++++++++++++++++++++++++++++ pyodata/v2/service.py | 11 +- pyodata/v4/__init__.py | 8 +- pyodata/v4/build_functions.py | 96 ++++++++- pyodata/v4/elements.py | 92 +++++++++ tests/conftest.py | 15 +- tests/metadata_v4.xml | 16 +- tests/test_model_v2.py | 5 +- tests/test_model_v4.py | 21 ++ 15 files changed, 688 insertions(+), 445 deletions(-) create mode 100644 pyodata/v2/elements.py create mode 100644 pyodata/v4/elements.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7821daf..86a823e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Splits python representation of metadata and metadata parsing - Martin Miksik - Separate type repositories for individual versions of OData - Martin Miksik - Support for OData V4 primitive types - Martin Miksik +- Support for navigation property in OData v4 - Martin Miksik ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik diff --git a/pyodata/model/build_functions.py b/pyodata/model/build_functions.py index f1b9a292..169b6ec9 100644 --- a/pyodata/model/build_functions.py +++ b/pyodata/model/build_functions.py @@ -5,11 +5,10 @@ import logging from pyodata.config import Config -from pyodata.exceptions import PyODataParserError, PyODataModelError +from pyodata.exceptions import PyODataParserError from pyodata.model.elements import sap_attribute_get_bool, sap_attribute_get_string, StructType, StructTypeProperty, \ - Identifier, Types, EnumType, EnumMember, EntitySet, ReferentialConstraint, PrincipalRole, DependentRole, \ - ValueHelper, ValueHelperParameter, FunctionImportParameter, FunctionImport, metadata_attribute_get, EntityType, \ - ComplexType, Annotation, build_element, Association, EndRole, AssociationSetEndRole, AssociationSet + Types, EnumType, EnumMember, EntitySet, ValueHelper, ValueHelperParameter, FunctionImportParameter, \ + FunctionImport, metadata_attribute_get, EntityType, ComplexType, Annotation, build_element def modlog(): @@ -55,6 +54,8 @@ def build_struct_type(config: Config, type_node, typ, schema=None): stype = copy.deepcopy(schema.get_type(base_type)) except KeyError: raise PyODataParserError(f'BaseType \'{base_type.name}\' not found in schema') + except AttributeError: + raise PyODataParserError(f'\'{base_type.name}\' ') stype._name = name @@ -168,98 +169,6 @@ def build_entity_set(config, entity_set_node): topable, req_filter, label) -def build_end_role(config: Config, end_role_node): - entity_type_info = Types.parse_type_name(end_role_node.get('Type')) - multiplicity = end_role_node.get('Multiplicity') - role = end_role_node.get('Role') - - return EndRole(entity_type_info, multiplicity, role) - - -def build_referential_constraint(config: Config, referential_constraint_node): - principal = referential_constraint_node.xpath('edm:Principal', namespaces=config.namespaces) - if len(principal) != 1: - raise RuntimeError('Referential constraint must contain exactly one principal element') - - principal_name = principal[0].get('Role') - if principal_name is None: - raise RuntimeError('Principal role name was not specified') - - principal_refs = [] - for property_ref in principal[0].xpath('edm:PropertyRef', namespaces=config.namespaces): - principal_refs.append(property_ref.get('Name')) - if not principal_refs: - raise RuntimeError('In role {} should be at least one principal property defined'.format(principal_name)) - - dependent = referential_constraint_node.xpath('edm:Dependent', namespaces=config.namespaces) - if len(dependent) != 1: - raise RuntimeError('Referential constraint must contain exactly one dependent element') - - dependent_name = dependent[0].get('Role') - if dependent_name is None: - raise RuntimeError('Dependent role name was not specified') - - dependent_refs = [] - for property_ref in dependent[0].xpath('edm:PropertyRef', namespaces=config.namespaces): - dependent_refs.append(property_ref.get('Name')) - if len(principal_refs) != len(dependent_refs): - raise RuntimeError('Number of properties should be equal for the principal {} and the dependent {}' - .format(principal_name, dependent_name)) - - return ReferentialConstraint( - PrincipalRole(principal_name, principal_refs), DependentRole(dependent_name, dependent_refs)) - - -# pylint: disable=protected-access -def build_association(config: Config, association_node): - name = association_node.get('Name') - association = Association(name) - - for end in association_node.xpath('edm:End', namespaces=config.namespaces): - end_role = build_element(EndRole, config, end_role_node=end) - if end_role.entity_type_info is None: - raise RuntimeError('End type is not specified in the association {}'.format(name)) - association._end_roles.append(end_role) - - if len(association._end_roles) != 2: - raise RuntimeError('Association {} does not have two end roles'.format(name)) - - refer = association_node.xpath('edm:ReferentialConstraint', namespaces=config.namespaces) - if len(refer) > 1: - raise RuntimeError('In association {} is defined more than one referential constraint'.format(name)) - - if not refer: - referential_constraint = None - else: - referential_constraint = build_element(ReferentialConstraint, config, referential_constraint_node=refer[0]) - - association._referential_constraint = referential_constraint - - return association - - -def build_association_set_end_role(config: Config, end_node): - role = end_node.get('Role') - entity_set = end_node.get('EntitySet') - - return AssociationSetEndRole(role, entity_set) - - -def build_association_set(config: Config, association_set_node): - end_roles = [] - name = association_set_node.get('Name') - association = Identifier.parse(association_set_node.get('Association')) - - end_roles_list = association_set_node.xpath('edm:End', namespaces=config.namespaces) - if len(end_roles) > 2: - raise PyODataModelError('Association {} cannot have more than 2 end roles'.format(name)) - - for end_role in end_roles_list: - end_roles.append(build_element(AssociationSetEndRole, config, end_node=end_role)) - - return AssociationSet(name, association.name, association.namespace, end_roles) - - def build_external_annotation(config, annotations_node): target = annotations_node.get('Target') diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 543cc245..482a870a 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -49,15 +49,6 @@ def build_element(element_name: Union[str, type], config: Config, **kwargs): raise PyODataParserError(f'{element_name} is unsupported in {config.odata_version.__name__}') -class NullAssociation: - def __init__(self, name): - self.name = name - - def __getattr__(self, item): - raise PyODataModelError('Cannot access this association. An error occurred during parsing ' - 'association metadata due to that annotation has been omitted.') - - class NullType: def __init__(self, name): self.name = name @@ -523,52 +514,6 @@ def function_import(self, function_import, namespace=None): def function_imports(self): return list(itertools.chain(*(decl.list_function_imports() for decl in list(self._decls.values())))) - def association(self, association_name, namespace=None): - if namespace is not None: - try: - return self._decls[namespace].associations[association_name] - except KeyError: - raise KeyError('Association {} does not exist in namespace {}'.format(association_name, namespace)) - for decl in list(self._decls.values()): - try: - return decl.associations[association_name] - except KeyError: - pass - - @property - def associations(self): - return list(itertools.chain(*(decl.list_associations() for decl in list(self._decls.values())))) - - def association_set_by_association(self, association_name, namespace=None): - if namespace is not None: - for association_set in list(self._decls[namespace].association_sets.values()): - if association_set.association_type.name == association_name: - return association_set - raise KeyError('Association Set for Association {} does not exist in Schema Namespace {}'.format( - association_name, namespace)) - for decl in list(self._decls.values()): - for association_set in list(decl.association_sets.values()): - if association_set.association_type.name == association_name: - return association_set - raise KeyError('Association Set for Association {} does not exist in any Schema Namespace'.format( - association_name)) - - def association_set(self, set_name, namespace=None): - if namespace is not None: - try: - return self._decls[namespace].association_sets[set_name] - except KeyError: - raise KeyError('Association set {} does not exist in namespace {}'.format(set_name, namespace)) - for decl in list(self._decls.values()): - try: - return decl.association_sets[set_name] - except KeyError: - pass - - @property - def association_sets(self): - return list(itertools.chain(*(decl.list_association_sets() for decl in list(self._decls.values())))) - def check_role_property_names(self, role, entity_type_name, namespace): for proprty in role.property_names: try: @@ -921,265 +866,6 @@ def value_helper(self, value): self._value_helper = value -class NavigationTypeProperty(VariableDeclaration): - """Defines a navigation property, which provides a reference to the other end of an association - - Unlike properties defined with the Property element, navigation properties do not define the - shape and characteristics of data. They provide a way to navigate an association between two - entity types. - - Note that navigation properties are optional on both entity types at the ends of an association. - If you define a navigation property on one entity type at the end of an association, you do not - have to define a navigation property on the entity type at the other end of the association. - - The data type returned by a navigation property is determined by the multiplicity of its remote - association end. For example, suppose a navigation property, OrdersNavProp, exists on a Customer - entity type and navigates a one-to-many association between Customer and Order. Because the - remote association end for the navigation property has multiplicity many (*), its data type is - a collection (of Order). Similarly, if a navigation property, CustomerNavProp, exists on the Order - entity type, its data type would be Customer since the multiplicity of the remote end is one (1). - """ - - def __init__(self, name, from_role_name, to_role_name, association_info): - super(NavigationTypeProperty, self).__init__(name, None, False, None, None, None) - - self.from_role_name = from_role_name - self.to_role_name = to_role_name - - self._association_info = association_info - self._association = None - - @property - def association_info(self): - return self._association_info - - @property - def association(self): - return self._association - - @association.setter - def association(self, value): - - if self._association is not None: - raise PyODataModelError('Cannot replace {0} of {1} to {2}'.format(self._association, self, value)) - - if value.name != self._association_info.name: - raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) - - self._association = value - - @property - def to_role(self): - return self._association.end_by_role(self.to_role_name) - - @property - def typ(self): - return self.to_role.entity_type - - -class EndRole: - MULTIPLICITY_ONE = '1' - MULTIPLICITY_ZERO_OR_ONE = '0..1' - MULTIPLICITY_ZERO_OR_MORE = '*' - - def __init__(self, entity_type_info, multiplicity, role): - self._entity_type_info = entity_type_info - self._entity_type = None - self._multiplicity = multiplicity - self._role = role - - def __repr__(self): - return "{0}({1})".format(self.__class__.__name__, self.role) - - @property - def entity_type_info(self): - return self._entity_type_info - - @property - def entity_type_name(self): - return self._entity_type_info.name - - @property - def entity_type(self): - return self._entity_type - - @entity_type.setter - def entity_type(self, value): - - if self._entity_type is not None: - raise PyODataModelError('Cannot replace {0} of {1} to {2}'.format(self._entity_type, self, value)) - - if value.name != self._entity_type_info.name: - raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) - - self._entity_type = value - - @property - def multiplicity(self): - return self._multiplicity - - @property - def role(self): - return self._role - - -class ReferentialConstraintRole: - def __init__(self, name, property_names): - self._name = name - self._property_names = property_names - - @property - def name(self): - return self._name - - @property - def property_names(self): - return self._property_names - - -class PrincipalRole(ReferentialConstraintRole): - pass - - -class DependentRole(ReferentialConstraintRole): - pass - - -class ReferentialConstraint(): - def __init__(self, principal, dependent): - self._principal = principal - self._dependent = dependent - - @property - def principal(self): - return self._principal - - @property - def dependent(self): - return self._dependent - - -class Association: - """Defines a relationship between two entity types. - - An association must specify the entity types that are involved in - the relationship and the possible number of entity types at each - end of the relationship, which is known as the multiplicity. - The multiplicity of an association end can have a value of one (1), - zero or one (0..1), or many (*). This information is specified in - two child End elements. - """ - - def __init__(self, name): - self._name = name - self._referential_constraint = None - self._end_roles = list() - - def __str__(self): - return '{0}({1})'.format(self.__class__.__name__, self._name) - - @property - def name(self): - return self._name - - @property - def end_roles(self): - return self._end_roles - - def end_by_role(self, end_role): - try: - return next((item for item in self._end_roles if item.role == end_role)) - except StopIteration: - raise KeyError('Association {} has no End with Role {}'.format(self._name, end_role)) - - @property - def referential_constraint(self): - return self._referential_constraint - - -class AssociationSetEndRole: - def __init__(self, role, entity_set_name): - self._role = role - self._entity_set_name = entity_set_name - self._entity_set = None - - def __repr__(self): - return "{0}({1})".format(self.__class__.__name__, self.role) - - @property - def role(self): - return self._role - - @property - def entity_set_name(self): - return self._entity_set_name - - @property - def entity_set(self): - return self._entity_set - - @entity_set.setter - def entity_set(self, value): - if self._entity_set: - raise PyODataModelError('Cannot replace {0} of {1} to {2}'.format(self._entity_set, self, value)) - - if value.name != self._entity_set_name: - raise PyODataModelError( - 'Assigned entity set {0} differentiates from the declared {1}'.format(value, self._entity_set_name)) - - self._entity_set = value - - -class AssociationSet: - def __init__(self, name, association_type_name, association_type_namespace, end_roles): - self._name = name - self._association_type_name = association_type_name - self._association_type_namespace = association_type_namespace - self._association_type = None - self._end_roles = end_roles - - def __str__(self): - return "{0}({1})".format(self.__class__.__name__, self._name) - - @property - def name(self): - return self._name - - @property - def association_type(self): - return self._association_type - - @property - def association_type_name(self): - return self._association_type_name - - @property - def association_type_namespace(self): - return self._association_type_namespace - - @property - def end_roles(self): - return self._end_roles - - def end_by_role(self, end_role): - try: - return next((end for end in self._end_roles if end.role == end_role)) - except StopIteration: - raise KeyError('Association set {} has no End with Role {}'.format(self._name, end_role)) - - def end_by_entity_set(self, entity_set): - try: - return next((end for end in self._end_roles if end.entity_set_name == entity_set)) - except StopIteration: - raise KeyError('Association set {} has no End with Entity Set {}'.format(self._name, entity_set)) - - @association_type.setter - def association_type(self, value): - if self._association_type is not None: - raise RuntimeError('Cannot replace {} of {} with {}'.format(self._association_type, self, value)) - self._association_type = value - - class Annotation(): Kinds = Enum('Kinds', 'ValueHelper') diff --git a/pyodata/policies.py b/pyodata/policies.py index 0d95339c..cb76a768 100644 --- a/pyodata/policies.py +++ b/pyodata/policies.py @@ -11,12 +11,14 @@ class ParserError(Enum): """ Represents all the different errors the parser is able to deal with.""" PROPERTY = auto() + NAVIGATION_PROPERTY = auto() ANNOTATION = auto() ASSOCIATION = auto() ENUM_TYPE = auto() ENTITY_TYPE = auto() COMPLEX_TYPE = auto() + REFERENTIAL_CONSTRAINT = auto() class ErrorPolicy(ABC): diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index cfc25820..89bb1ead 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -9,9 +9,12 @@ EdmLongIntTypTraits, EdmStringTypTraits from pyodata.config import ODATAVersion +from pyodata.v2.elements import NavigationTypeProperty, EndRole, Association, AssociationSetEndRole, AssociationSet, \ + ReferentialConstraint, Schema from pyodata.model.elements import StructTypeProperty, StructType, ComplexType, EntityType, \ - EnumType, EntitySet, ReferentialConstraint, ExternalAnnotation, Annotation, ValueHelper, ValueHelperParameter, \ - FunctionImport, Schema, Typ, NavigationTypeProperty, EndRole, Association, AssociationSetEndRole, AssociationSet + EnumType, EntitySet, ExternalAnnotation, Annotation, ValueHelper, ValueHelperParameter, \ + FunctionImport, Typ + import pyodata.v2.build_functions as build_functions_v2 import pyodata.model.build_functions as build_functions @@ -35,11 +38,11 @@ def build_functions(): EntityType: build_functions.build_entity_type, EnumType: build_functions.build_enum_type, EntitySet: build_functions.build_entity_set, - EndRole: build_functions.build_end_role, - ReferentialConstraint: build_functions.build_referential_constraint, - Association: build_functions.build_association, - AssociationSetEndRole: build_functions.build_association_set_end_role, - AssociationSet: build_functions.build_association_set, + EndRole: build_functions_v2.build_end_role, + ReferentialConstraint: build_functions_v2.build_referential_constraint, + Association: build_functions_v2.build_association, + AssociationSetEndRole: build_functions_v2.build_association_set_end_role, + AssociationSet: build_functions_v2.build_association_set, ExternalAnnotation: build_functions.build_external_annotation, Annotation: build_functions.build_annotation, ValueHelper: build_functions.build_value_helper, diff --git a/pyodata/v2/build_functions.py b/pyodata/v2/build_functions.py index dc31179d..7afb82ce 100644 --- a/pyodata/v2/build_functions.py +++ b/pyodata/v2/build_functions.py @@ -5,13 +5,15 @@ import itertools import logging +from typing import List from pyodata.config import Config from pyodata.exceptions import PyODataParserError, PyODataModelError -from pyodata.model.elements import EntityType, ComplexType, Schema, EnumType, NullType, build_element, \ - NullAssociation, EntitySet, FunctionImport, AssociationSet, ExternalAnnotation, Annotation, Association, Typ, \ - NavigationTypeProperty, Identifier +from pyodata.model.elements import EntityType, ComplexType, EnumType, NullType, build_element, \ + EntitySet, FunctionImport, ExternalAnnotation, Annotation, Typ, Identifier, Types from pyodata.policies import ParserError +from pyodata.v2.elements import AssociationSetEndRole, Association, AssociationSet, NavigationTypeProperty, EndRole, \ + Schema, NullAssociation, ReferentialConstraint, PrincipalRole, DependentRole def modlog(): @@ -19,11 +21,6 @@ def modlog(): return logging.getLogger("v2_build_functions") -def build_navigation_type_property(config: Config, node): - return NavigationTypeProperty( - node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship'))) - - # pylint: disable=protected-access,too-many-locals, too-many-branches,too-many-statements # While building schema it is necessary to set few attributes which in the rest of the application should remain # constant. As for now, splitting build_schema into sub-functions would not add any benefits. @@ -245,3 +242,100 @@ def build_schema(config: Config, schema_nodes): except (RuntimeError, PyODataModelError) as ex: config.err_policy(ParserError.ANNOTATION).resolve(ex) return schema + + +def build_navigation_type_property(config: Config, node): + return NavigationTypeProperty( + node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship'))) + + +def build_end_role(config: Config, end_role_node): + entity_type_info = Types.parse_type_name(end_role_node.get('Type')) + multiplicity = end_role_node.get('Multiplicity') + role = end_role_node.get('Role') + + return EndRole(entity_type_info, multiplicity, role) + + +# pylint: disable=protected-access +def build_association(config: Config, association_node): + name = association_node.get('Name') + association = Association(name) + + for end in association_node.xpath('edm:End', namespaces=config.namespaces): + end_role = build_element(EndRole, config, end_role_node=end) + if end_role.entity_type_info is None: + raise RuntimeError('End type is not specified in the association {}'.format(name)) + association._end_roles.append(end_role) + + if len(association._end_roles) != 2: + raise RuntimeError('Association {} does not have two end roles'.format(name)) + + refer = association_node.xpath('edm:ReferentialConstraint', namespaces=config.namespaces) + if len(refer) > 1: + raise RuntimeError('In association {} is defined more than one referential constraint'.format(name)) + + if not refer: + referential_constraint = None + else: + referential_constraint = build_element(ReferentialConstraint, config, referential_constraint_node=refer[0]) + + association._referential_constraint = referential_constraint + + return association + + +def build_association_set_end_role(config: Config, end_node): + role = end_node.get('Role') + entity_set = end_node.get('EntitySet') + + return AssociationSetEndRole(role, entity_set) + + +def build_association_set(config: Config, association_set_node): + end_roles: List[AssociationSetEndRole] = [] + name = association_set_node.get('Name') + association = Identifier.parse(association_set_node.get('Association')) + + end_roles_list = association_set_node.xpath('edm:End', namespaces=config.namespaces) + if len(end_roles) > 2: + raise PyODataModelError('Association {} cannot have more than 2 end roles'.format(name)) + + for end_role in end_roles_list: + end_roles.append(build_element(AssociationSetEndRole, config, end_node=end_role)) + + return AssociationSet(name, association.name, association.namespace, end_roles) + + +def build_referential_constraint(config: Config, referential_constraint_node): + principal = referential_constraint_node.xpath('edm:Principal', namespaces=config.namespaces) + if len(principal) != 1: + raise RuntimeError('Referential constraint must contain exactly one principal element') + + principal_name = principal[0].get('Role') + if principal_name is None: + raise RuntimeError('Principal role name was not specified') + + principal_refs = [] + for property_ref in principal[0].xpath('edm:PropertyRef', namespaces=config.namespaces): + principal_refs.append(property_ref.get('Name')) + if not principal_refs: + raise RuntimeError('In role {} should be at least one principal property defined'.format(principal_name)) + + dependent = referential_constraint_node.xpath('edm:Dependent', namespaces=config.namespaces) + if len(dependent) != 1: + raise RuntimeError('Referential constraint must contain exactly one dependent element') + + dependent_name = dependent[0].get('Role') + if dependent_name is None: + raise RuntimeError('Dependent role name was not specified') + + dependent_refs = [] + for property_ref in dependent[0].xpath('edm:PropertyRef', namespaces=config.namespaces): + dependent_refs.append(property_ref.get('Name')) + if len(principal_refs) != len(dependent_refs): + raise RuntimeError('Number of properties should be equal for the principal {} and the dependent {}' + .format(principal_name, dependent_name)) + + return ReferentialConstraint( + PrincipalRole(principal_name, principal_refs), DependentRole(dependent_name, dependent_refs)) diff --git a/pyodata/v2/elements.py b/pyodata/v2/elements.py new file mode 100644 index 00000000..c1bb5fbb --- /dev/null +++ b/pyodata/v2/elements.py @@ -0,0 +1,324 @@ +""" Repository of elements specific to the ODATA V2""" +# pylint: disable=missing-docstring + +import itertools + +from pyodata import model +from pyodata.exceptions import PyODataModelError +from pyodata.model.elements import VariableDeclaration + + +class NullAssociation: + def __init__(self, name): + self.name = name + + def __getattr__(self, item): + raise PyODataModelError('Cannot access this association. An error occurred during parsing ' + 'association metadata due to that annotation has been omitted.') + + +class NavigationTypeProperty(VariableDeclaration): + """Defines a navigation property, which provides a reference to the other end of an association + + Unlike properties defined with the Property element, navigation properties do not define the + shape and characteristics of data. They provide a way to navigate an association between two + entity types. + + Note that navigation properties are optional on both entity types at the ends of an association. + If you define a navigation property on one entity type at the end of an association, you do not + have to define a navigation property on the entity type at the other end of the association. + + The data type returned by a navigation property is determined by the multiplicity of its remote + association end. For example, suppose a navigation property, OrdersNavProp, exists on a Customer + entity type and navigates a one-to-many association between Customer and Order. Because the + remote association end for the navigation property has multiplicity many (*), its data type is + a collection (of Order). Similarly, if a navigation property, CustomerNavProp, exists on the Order + entity type, its data type would be Customer since the multiplicity of the remote end is one (1). + """ + + def __init__(self, name, from_role_name, to_role_name, association_info): + super(NavigationTypeProperty, self).__init__(name, None, False, None, None, None) + + self.from_role_name = from_role_name + self.to_role_name = to_role_name + + self._association_info = association_info + self._association = None + + @property + def association_info(self): + return self._association_info + + @property + def association(self): + return self._association + + @association.setter + def association(self, value): + + if self._association is not None: + raise PyODataModelError('Cannot replace {0} of {1} to {2}'.format(self._association, self, value)) + + if value.name != self._association_info.name: + raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) + + self._association = value + + @property + def to_role(self): + return self._association.end_by_role(self.to_role_name) + + @property # type: ignore + def typ(self): + return self.to_role.entity_type + + +class EndRole: + MULTIPLICITY_ONE = '1' + MULTIPLICITY_ZERO_OR_ONE = '0..1' + MULTIPLICITY_ZERO_OR_MORE = '*' + + def __init__(self, entity_type_info, multiplicity, role): + self._entity_type_info = entity_type_info + self._entity_type = None + self._multiplicity = multiplicity + self._role = role + + def __repr__(self): + return "{0}({1})".format(self.__class__.__name__, self.role) + + @property + def entity_type_info(self): + return self._entity_type_info + + @property + def entity_type_name(self): + return self._entity_type_info.name + + @property + def entity_type(self): + return self._entity_type + + @entity_type.setter + def entity_type(self, value): + + if self._entity_type is not None: + raise PyODataModelError('Cannot replace {0} of {1} to {2}'.format(self._entity_type, self, value)) + + if value.name != self._entity_type_info.name: + raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) + + self._entity_type = value + + @property + def multiplicity(self): + return self._multiplicity + + @property + def role(self): + return self._role + + +class Association: + """Defines a relationship between two entity types. + + An association must specify the entity types that are involved in + the relationship and the possible number of entity types at each + end of the relationship, which is known as the multiplicity. + The multiplicity of an association end can have a value of one (1), + zero or one (0..1), or many (*). This information is specified in + two child End elements. + """ + + def __init__(self, name): + self._name = name + self._referential_constraint = None + self._end_roles = list() + + def __str__(self): + return '{0}({1})'.format(self.__class__.__name__, self._name) + + @property + def name(self): + return self._name + + @property + def end_roles(self): + return self._end_roles + + def end_by_role(self, end_role): + try: + return next((item for item in self._end_roles if item.role == end_role)) + except StopIteration: + raise KeyError('Association {} has no End with Role {}'.format(self._name, end_role)) + + @property + def referential_constraint(self): + return self._referential_constraint + + +class AssociationSetEndRole: + def __init__(self, role, entity_set_name): + self._role = role + self._entity_set_name = entity_set_name + self._entity_set = None + + def __repr__(self): + return "{0}({1})".format(self.__class__.__name__, self.role) + + @property + def role(self): + return self._role + + @property + def entity_set_name(self): + return self._entity_set_name + + @property + def entity_set(self): + return self._entity_set + + @entity_set.setter + def entity_set(self, value): + if self._entity_set: + raise PyODataModelError('Cannot replace {0} of {1} to {2}'.format(self._entity_set, self, value)) + + if value.name != self._entity_set_name: + raise PyODataModelError( + 'Assigned entity set {0} differentiates from the declared {1}'.format(value, self._entity_set_name)) + + self._entity_set = value + + +class AssociationSet: + def __init__(self, name, association_type_name, association_type_namespace, end_roles): + self._name = name + self._association_type_name = association_type_name + self._association_type_namespace = association_type_namespace + self._association_type = None + self._end_roles = end_roles + + def __str__(self): + return "{0}({1})".format(self.__class__.__name__, self._name) + + @property + def name(self): + return self._name + + @property + def association_type(self): + return self._association_type + + @association_type.setter + def association_type(self, value): + if self._association_type is not None: + raise RuntimeError('Cannot replace {} of {} with {}'.format(self._association_type, self, value)) + self._association_type = value + + @property + def association_type_name(self): + return self._association_type_name + + @property + def association_type_namespace(self): + return self._association_type_namespace + + @property + def end_roles(self): + return self._end_roles + + def end_by_role(self, end_role): + try: + return next((end for end in self._end_roles if end.role == end_role)) + except StopIteration: + raise KeyError('Association set {} has no End with Role {}'.format(self._name, end_role)) + + def end_by_entity_set(self, entity_set): + try: + return next((end for end in self._end_roles if end.entity_set_name == entity_set)) + except StopIteration: + raise KeyError('Association set {} has no End with Entity Set {}'.format(self._name, entity_set)) + + +class ReferentialConstraintRole: + def __init__(self, name, property_names): + self._name = name + self._property_names = property_names + + @property + def name(self): + return self._name + + @property + def property_names(self): + return self._property_names + + +class PrincipalRole(ReferentialConstraintRole): + pass + + +class DependentRole(ReferentialConstraintRole): + pass + + +class ReferentialConstraint: + def __init__(self, principal, dependent): + self._principal = principal + self._dependent = dependent + + @property + def principal(self): + return self._principal + + @property + def dependent(self): + return self._dependent + + +class Schema(model.elements.Schema): + def association(self, association_name, namespace=None): + if namespace is not None: + try: + return self._decls[namespace].associations[association_name] + except KeyError: + raise KeyError('Association {} does not exist in namespace {}'.format(association_name, namespace)) + for decl in list(self._decls.values()): + try: + return decl.associations[association_name] + except KeyError: + pass + + @property + def associations(self): + return list(itertools.chain(*(decl.list_associations() for decl in list(self._decls.values())))) + + def association_set_by_association(self, association_name, namespace=None): + if namespace is not None: + for association_set in list(self._decls[namespace].association_sets.values()): + if association_set.association_type.name == association_name: + return association_set + raise KeyError('Association Set for Association {} does not exist in Schema Namespace {}'.format( + association_name, namespace)) + for decl in list(self._decls.values()): + for association_set in list(decl.association_sets.values()): + if association_set.association_type.name == association_name: + return association_set + raise KeyError('Association Set for Association {} does not exist in any Schema Namespace'.format( + association_name)) + + def association_set(self, set_name, namespace=None): + if namespace is not None: + try: + return self._decls[namespace].association_sets[set_name] + except KeyError: + raise KeyError('Association set {} does not exist in namespace {}'.format(set_name, namespace)) + for decl in list(self._decls.values()): + try: + return decl.association_sets[set_name] + except KeyError: + pass + + @property + def association_sets(self): + return list(itertools.chain(*(decl.list_association_sets() for decl in list(self._decls.values())))) diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index 23b53f6c..c81c74ff 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -17,6 +17,7 @@ import requests from pyodata.model import elements +from pyodata.v2 import elements as elements_v2 from pyodata.exceptions import HttpError, PyODataException, ExpressionError LOGGER_NAME = 'pyodata.service' @@ -736,8 +737,8 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key= # cache value according to multiplicity if prop.to_role.multiplicity in \ - [elements.EndRole.MULTIPLICITY_ONE, - elements.EndRole.MULTIPLICITY_ZERO_OR_ONE]: + [elements_v2.EndRole.MULTIPLICITY_ONE, + elements_v2.EndRole.MULTIPLICITY_ZERO_OR_ONE]: # cache None in case we receive nothing (null) instead of entity data if proprties[prop.name] is None: @@ -745,7 +746,7 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key= else: self._cache[prop.name] = EntityProxy(service, None, prop_etype, proprties[prop.name]) - elif prop.to_role.multiplicity == elements.EndRole.MULTIPLICITY_ZERO_OR_MORE: + elif prop.to_role.multiplicity == elements_v2.EndRole.MULTIPLICITY_ZERO_OR_MORE: # default value is empty array self._cache[prop.name] = [] @@ -815,7 +816,7 @@ def nav(self, nav_property): raise PyODataException('No association set for role {}'.format(navigation_property.to_role)) roles = navigation_property.association.end_roles - if all((role.multiplicity != elements.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): + if all((role.multiplicity != elements_v2.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): return NavEntityProxy(self, nav_property, navigation_entity_set.entity_type, {}) return EntitySetProxy( @@ -1024,7 +1025,7 @@ def nav(self, nav_property, key): 'No association set for role {} {}'.format(navigation_property.to_role, association_set.end_roles)) roles = navigation_property.association.end_roles - if all((role.multiplicity != elements.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): + if all((role.multiplicity != elements_v2.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): return self._get_nav_entity(key, nav_property, navigation_entity_set) return EntitySetProxy( diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py index f9bdf746..5d1812c6 100644 --- a/pyodata/v4/__init__.py +++ b/pyodata/v4/__init__.py @@ -4,11 +4,12 @@ from pyodata.config import ODATAVersion from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits -from pyodata.model.elements import Typ, Schema, EnumType, ComplexType, StructType, StructTypeProperty +from pyodata.model.elements import Typ, Schema, EnumType, ComplexType, StructType, StructTypeProperty, EntityType + +from pyodata.v4.elements import NavigationTypeProperty from pyodata.v4.type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration - import pyodata.v4.build_functions as build_functions_v4 import pyodata.model.build_functions as build_functions @@ -21,9 +22,10 @@ def build_functions(): return { StructTypeProperty: build_functions.build_struct_type_property, StructType: build_functions.build_struct_type, - # NavigationTypeProperty: navigation_type_property_from_etree, + NavigationTypeProperty: build_functions_v4.build_navigation_type_property, EnumType: build_functions.build_enum_type, ComplexType: build_functions.build_complex_type, + EntityType: build_functions.build_entity_type, Schema: build_functions_v4.build_schema, } diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index 17df7eb6..ff2eb07d 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -1,16 +1,19 @@ """ Repository of build functions specific to the ODATA V4""" # pylint: disable=missing-docstring +import itertools from pyodata.config import Config -from pyodata.exceptions import PyODataParserError -from pyodata.model.elements import ComplexType, Schema, EnumType, NullType, build_element +from pyodata.exceptions import PyODataParserError, PyODataModelError +from pyodata.model.elements import ComplexType, Schema, EnumType, NullType, build_element, EntityType, Types,\ + StructTypeProperty from pyodata.policies import ParserError +from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint -# pylint: disable=protected-access +# pylint: disable=protected-access,too-many-locals,too-many-branches,too-many-statements # While building schema it is necessary to set few attributes which in the rest of the application should remain -# constant. +# constant. As for now, splitting build_schema into sub-functions would not add any benefits. def build_schema(config: Config, schema_nodes): schema = Schema(config) @@ -43,10 +46,89 @@ def build_schema(config: Config, schema_nodes): decl.add_complex_type(ctype) - # TODO: resolve types of properties + for entity_type in schema_node.xpath('edm:EntityType', namespaces=config.namespaces): + try: + etype = build_element(EntityType, config, type_node=entity_type, schema=schema) + except (KeyError, AttributeError) as ex: + config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) + etype = NullType(entity_type.get('Name')) + + decl.add_entity_type(etype) + + # resolve types of properties + for stype in itertools.chain(schema.entity_types, schema.complex_types): + if isinstance(stype, NullType) or stype.is_collection: + continue + + prop: StructTypeProperty + for prop in stype.proprties(): + try: + prop.typ = schema.get_type(prop.type_info) + except (PyODataModelError, AttributeError) as ex: + config.err_policy(ParserError.PROPERTY).resolve(ex) + prop.typ = NullType(prop.type_info.name) + + if not isinstance(stype, EntityType): + continue + + for nav_prop in stype.nav_proprties: + try: + nav_prop.typ = schema.get_type(nav_prop.type_info) + except (PyODataModelError, AttributeError) as ex: + config.err_policy(ParserError.NAVIGATION_PROPERTY).resolve(ex) + nav_prop.typ = NullType(nav_prop.type_info.name) + + # resolve partners and referential constraints of navigation properties after typ of navigation properties + # are resolved + for stype in schema.entity_types: + if isinstance(stype, NullType) or stype.is_collection: + continue + + for nav_prop in stype.nav_proprties: + if nav_prop.partner_info: + try: + # Navigation properties of nav_prop.typ + nav_properties = nav_prop.typ.item_type.nav_proprties if nav_prop.typ.is_collection \ + else nav_prop.typ.nav_proprties + try: + nav_prop.partner = next(filter(lambda x: x.name == nav_prop.partner_info.name, nav_properties)) + except StopIteration: + raise PyODataModelError(f'No navigation property with name ' + f'"{nav_prop.partner_info.name}" found in "{nav_prop.typ}"') + except PyODataModelError as ex: + config.err_policy(ParserError.NAVIGATION_PROPERTY).resolve(ex) + nav_prop.partner = NullProperty(nav_prop.partner_info.name) + + for ref_con in nav_prop.referential_constraints: + try: + proprty = stype.proprty(ref_con.proprty_name) + referenced_proprty = nav_prop.typ.proprty(ref_con.referenced_proprty_name) + except PyODataModelError as ex: + config.err_policy(ParserError.REFERENTIAL_CONSTRAINT).resolve(ex) + proprty = NullProperty(ref_con.proprty_name) + referenced_proprty = NullProperty(ref_con.referenced_proprty_name) + + ref_con.proprty = proprty + ref_con.referenced_proprty = referenced_proprty + # TODO: Then, process Associations nodes because they refer EntityTypes and they are referenced by AssociationSets. - # TODO: resolve navigation properties # TODO: Then, process EntitySet, FunctionImport and AssociationSet nodes. # TODO: Finally, process Annotation nodes when all Scheme nodes are completely processed. - return Schema + return schema + + +def build_navigation_type_property(config: Config, node): + partner = Types.parse_type_name(node.get('Partner')) if node.get('Partner') else None + ref_cons = [] + + for ref_con in node.xpath('edm:ReferentialConstraint', namespaces=config.namespaces): + ref_cons.append(ReferentialConstraint(ref_con.get('Property'), ref_con.get('ReferencedProperty'))) + + return NavigationTypeProperty( + node.get('Name'), + Types.parse_type_name(node.get('Type')), + node.get('nullable'), + partner, + node.get('contains_target'), + ref_cons) diff --git a/pyodata/v4/elements.py b/pyodata/v4/elements.py new file mode 100644 index 00000000..d905afbd --- /dev/null +++ b/pyodata/v4/elements.py @@ -0,0 +1,92 @@ +""" Repository of elements specific to the ODATA V4""" +from typing import Optional, List + +from pyodata.exceptions import PyODataModelError +from pyodata.model.elements import VariableDeclaration, StructType + + +class NullProperty: + """ Defines fallback class when parser is unable to process property defined in xml """ + def __init__(self, name): + self.name = name + + def __getattr__(self, item): + raise PyODataModelError(f'Cannot access this property. An error occurred during parsing property stated in ' + f'xml({self.name}) and it was not found, therefore it has been replaced with ' + f'NullProperty.') + + +# pylint: disable=missing-docstring +# Purpose of properties is obvious and also they have type hints. +class ReferentialConstraint: + """ Defines a edm.ReferentialConstraint + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part3-csdl/odata-v4.0-errata03-os-part3-csdl-complete.html#_Toc453752543 + """ + def __init__(self, proprty_name: str, referenced_proprty_name: str): + self._proprty_name = proprty_name + self._referenced_proprty_name = referenced_proprty_name + self._property: Optional[VariableDeclaration] = None + self._referenced_property: Optional[VariableDeclaration] = None + + def __repr__(self): + return f"{self.__class__.__name__}({self.proprty}, {self.referenced_proprty})" + + def __str__(self): + return f"{self.__class__.__name__}({self.proprty}, {self.referenced_proprty})" + + @property + def proprty_name(self): + return self._proprty_name + + @property + def referenced_proprty_name(self): + return self._referenced_proprty_name + + @property + def proprty(self) -> Optional[VariableDeclaration]: + return self._property + + @proprty.setter + def proprty(self, value: VariableDeclaration): + self._property = value + + @property + def referenced_proprty(self) -> Optional[VariableDeclaration]: + return self._referenced_property + + @referenced_proprty.setter + def referenced_proprty(self, value: VariableDeclaration): + self._referenced_property = value + + +class NavigationTypeProperty(VariableDeclaration): + """Defines a navigation property, which provides a reference to the other end of an association + """ + + def __init__(self, name, type_info, nullable, partner_info, contains_target, referential_constraints): + super().__init__(name, type_info, nullable, None, None, None) + + self._partner_info = partner_info + self._partner = None + self._contains_target = contains_target + self._referential_constraints = referential_constraints + + @property + def partner_info(self): + return self._partner_info + + @property + def contains_target(self): + return self._contains_target + + @property + def partner(self): + return self._partner + + @partner.setter + def partner(self, value: StructType): + self._partner = value + + @property + def referential_constraints(self) -> List[ReferentialConstraint]: + return self._referential_constraints diff --git a/tests/conftest.py b/tests/conftest.py index bd598f25..34f31fe1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,10 @@ import logging import os import pytest -from pyodata.model.builder import schema_from_xml + +from pyodata.config import Config +from pyodata.model.builder import schema_from_xml, MetadataBuilder +from pyodata.v4 import ODataV4 @pytest.fixture @@ -124,6 +127,16 @@ def schema(metadata_v2): return schema_from_xml(metadata_v2) +@pytest.fixture +def schema_v4(metadata_v4): + meta = MetadataBuilder( + metadata_v4, + config=Config(ODataV4) + ) + + return meta.build() + + def assert_logging_policy(mock_warning, *args): """Assert if an warning was outputted by PolicyWarning """ assert logging.Logger.warning is mock_warning diff --git a/tests/metadata_v4.xml b/tests/metadata_v4.xml index fffdc03e..560b409c 100644 --- a/tests/metadata_v4.xml +++ b/tests/metadata_v4.xml @@ -55,7 +55,7 @@ - + @@ -134,6 +134,20 @@ + + + + + + + + + + + + + + diff --git a/tests/test_model_v2.py b/tests/test_model_v2.py index fbd6641b..a00ee0be 100644 --- a/tests/test_model_v2.py +++ b/tests/test_model_v2.py @@ -8,13 +8,12 @@ from tests.conftest import assert_logging_policy from pyodata.config import Config from pyodata.model.builder import MetadataBuilder -from pyodata.model.elements import Typ, Types, EntityType, TypeInfo, NullType, NullAssociation, Schema, \ - StructTypeProperty, EndRole, AssociationSetEndRole, AssociationSet, Association +from pyodata.model.elements import Typ, Types, EntityType, TypeInfo, NullType, Schema, StructTypeProperty from pyodata.model.type_traits import EdmStructTypeSerializer from pyodata.policies import ParserError, PolicyWarning, PolicyIgnore, PolicyFatal from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError from pyodata.v2 import ODataV2 - +from pyodata.v2.elements import EndRole, AssociationSet, AssociationSetEndRole, Association, NullAssociation def test_edmx(schema): diff --git a/tests/test_model_v4.py b/tests/test_model_v4.py index 8a3513ba..f108b1f7 100644 --- a/tests/test_model_v4.py +++ b/tests/test_model_v4.py @@ -11,6 +11,7 @@ from pyodata.config import Config from pyodata.v4 import ODataV4 from tests.conftest import metadata +from v4 import NavigationTypeProperty def test_type_traits(): @@ -151,3 +152,23 @@ def test_schema(metadata_v4): meta_builder.build() + +def test_edmx_navigation_properties(schema_v4): + """Test parsing of navigation properties""" + + entity = schema_v4.entity_type('Person') + assert str(entity) == 'EntityType(Person)' + assert entity.name == 'Person' + + nav_prop = entity.nav_proprty('Friends') + assert str(nav_prop) == 'NavigationTypeProperty(Friends)' + assert repr(nav_prop.typ) == 'Collection(EntityType(Person))' + assert repr(nav_prop.partner) == 'NavigationTypeProperty(Friends)' + + +def test_referential_constraint(schema_v4): + nav_property: NavigationTypeProperty = schema_v4.entity_type('Product').nav_proprty('Category') + assert str(nav_property) == 'NavigationTypeProperty(Category)' + assert repr(nav_property.referential_constraints[0]) == \ + 'ReferentialConstraint(StructTypeProperty(CategoryID), StructTypeProperty(ID))' + From c7f8f7267dbbc669e0ab9df7e4b8e149458c1b57 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Wed, 30 Oct 2019 15:27:13 +0100 Subject: [PATCH 08/36] Remove enum type from OData V2 Despite implementing enum for V2 the first mention of enum in specification is only in later versions of OData. Thus, it should not be possible to parse enum using V2 parser. --- pyodata/config.py | 1 - pyodata/model/build_functions.py | 52 +--------------- pyodata/model/elements.py | 71 +--------------------- pyodata/model/type_traits.py | 18 ------ pyodata/v2/__init__.py | 6 +- pyodata/v2/build_functions.py | 15 +---- pyodata/v4/__init__.py | 6 +- pyodata/v4/build_functions.py | 55 ++++++++++++++++- pyodata/v4/elements.py | 77 +++++++++++++++++++++++- pyodata/v4/type_traits.py | 19 ++++++ tests/metadata.xml | 18 ------ tests/metadata_v4.xml | 14 +++++ tests/test_model_v2.py | 82 ------------------------- tests/test_model_v4.py | 100 +++++++++++++++++++++++++++++-- tests/test_service_v2.py | 26 -------- 15 files changed, 264 insertions(+), 296 deletions(-) diff --git a/pyodata/config.py b/pyodata/config.py index 6ffd5d84..00ab6601 100644 --- a/pyodata/config.py +++ b/pyodata/config.py @@ -22,7 +22,6 @@ def __init__(self): # Separate dictionary of all registered types (primitive, complex and collection variants) for each child Types: Dict[str, 'Typ'] = dict() - @staticmethod @abstractmethod def primitive_types() -> List['Typ']: diff --git a/pyodata/model/build_functions.py b/pyodata/model/build_functions.py index 169b6ec9..78fb545d 100644 --- a/pyodata/model/build_functions.py +++ b/pyodata/model/build_functions.py @@ -7,7 +7,7 @@ from pyodata.config import Config from pyodata.exceptions import PyODataParserError from pyodata.model.elements import sap_attribute_get_bool, sap_attribute_get_string, StructType, StructTypeProperty, \ - Types, EnumType, EnumMember, EntitySet, ValueHelper, ValueHelperParameter, FunctionImportParameter, \ + Types, EntitySet, ValueHelper, ValueHelperParameter, FunctionImportParameter, \ FunctionImport, metadata_attribute_get, EntityType, ComplexType, Annotation, build_element @@ -99,56 +99,6 @@ def build_entity_type(config: Config, type_node, schema=None): return etype -# pylint: disable=protected-access, too-many-locals -def build_enum_type(config: Config, type_node, namespace): - ename = type_node.get('Name') - is_flags = type_node.get('IsFlags') - - # namespace = kwargs['namespace'] - - underlying_type = type_node.get('UnderlyingType') - - # https://docs.oasis-open.org/odata/odata-csdl-json/v4.01/csprd04/odata-csdl-json-v4.01-csprd04.html#sec_EnumerationType - if underlying_type is None: - underlying_type = 'Edm.Int32' - - valid_types = { - 'Edm.Byte': [0, 2 ** 8 - 1], - 'Edm.Int16': [-2 ** 15, 2 ** 15 - 1], - 'Edm.Int32': [-2 ** 31, 2 ** 31 - 1], - 'Edm.Int64': [-2 ** 63, 2 ** 63 - 1], - 'Edm.SByte': [-2 ** 7, 2 ** 7 - 1] - } - - if underlying_type not in valid_types: - raise PyODataParserError( - f'Type {underlying_type} is not valid as underlying type for EnumType - must be one of {valid_types}') - - mtype = Types.from_name(underlying_type, config) - etype = EnumType(ename, is_flags, mtype, namespace) - - members = type_node.xpath('edm:Member', namespaces=config.namespaces) - - next_value = 0 - for member in members: - name = member.get('Name') - value = member.get('Value') - - if value is not None: - next_value = int(value) - - vtype = valid_types[underlying_type] - if not vtype[0] < next_value < vtype[1]: - raise PyODataParserError(f'Value {next_value} is out of range for type {underlying_type}') - - emember = EnumMember(etype, name, next_value) - etype._member.append(emember) - - next_value += 1 - - return etype - - def build_entity_set(config, entity_set_node): name = entity_set_node.get('Name') et_info = Types.parse_type_name(entity_set_node.get('EntityType')) diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 482a870a..e0a4cec8 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -9,7 +9,7 @@ from pyodata.config import Config from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError -from pyodata.model.type_traits import TypTraits, EdmStructTypTraits, EnumTypTrait +from pyodata.model.type_traits import TypTraits, EdmStructTypTraits IdentifierInfo = collections.namedtuple('IdentifierInfo', 'namespace name') @@ -573,75 +573,6 @@ class ComplexType(StructType): """Representation of Edm.ComplexType""" -class EnumMember: - def __init__(self, parent, name, value): - self._parent = parent - self._name = name - self._value = value - - def __str__(self): - return f"{self._parent.name}\'{self._name}\'" - - @property - def name(self): - return self._name - - @property - def value(self): - return self._value - - @property - def parent(self): - return self._parent - - -class EnumType(Identifier): - def __init__(self, name, is_flags, underlying_type, namespace): - super(EnumType, self).__init__(name) - self._member = list() - self._underlying_type = underlying_type - self._traits = TypTraits() - self._namespace = namespace - - if is_flags == 'True': - self._is_flags = True - else: - self._is_flags = False - - def __str__(self): - return f"{self.__class__.__name__}({self._name})" - - def __getattr__(self, item): - member = next(filter(lambda x: x.name == item, self._member), None) - if member is None: - raise PyODataException(f'EnumType {self} has no member {item}') - - return member - - def __getitem__(self, item): - # If the item is type string then we want to check for members with that name instead - if isinstance(item, str): - return self.__getattr__(item) - - member = next(filter(lambda x: x.value == int(item), self._member), None) - if member is None: - raise PyODataException(f'EnumType {self} has no member with value {item}') - - return member - - @property - def is_flags(self): - return self._is_flags - - @property - def traits(self): - return EnumTypTrait(self) - - @property - def namespace(self): - return self._namespace - - class EntityType(StructType): def __init__(self, name, label, is_value_list): super(EntityType, self).__init__(name, label, is_value_list) diff --git a/pyodata/model/type_traits.py b/pyodata/model/type_traits.py index a3c6e74c..9c6b2961 100644 --- a/pyodata/model/type_traits.py +++ b/pyodata/model/type_traits.py @@ -178,21 +178,3 @@ def from_json(self, value): def from_literal(self, value): return EdmStructTypeSerializer.from_json(self._edm_type, value) - - -class EnumTypTrait(TypTraits): - def __init__(self, enum_type): - self._enum_type = enum_type - - def to_literal(self, value): - return f'{value.parent.namespace}.{value}' - - def from_json(self, value): - return getattr(self._enum_type, value) - - def from_literal(self, value): - # remove namespaces - enum_value = value.split('.')[-1] - # remove enum type - name = enum_value.split("'")[1] - return getattr(self._enum_type, name) diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index 89bb1ead..27d8af47 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -11,9 +11,8 @@ from pyodata.v2.elements import NavigationTypeProperty, EndRole, Association, AssociationSetEndRole, AssociationSet, \ ReferentialConstraint, Schema -from pyodata.model.elements import StructTypeProperty, StructType, ComplexType, EntityType, \ - EnumType, EntitySet, ExternalAnnotation, Annotation, ValueHelper, ValueHelperParameter, \ - FunctionImport, Typ +from pyodata.model.elements import StructTypeProperty, StructType, ComplexType, EntityType, EntitySet, \ + ExternalAnnotation, Annotation, ValueHelper, ValueHelperParameter, FunctionImport, Typ import pyodata.v2.build_functions as build_functions_v2 @@ -36,7 +35,6 @@ def build_functions(): NavigationTypeProperty: build_functions_v2.build_navigation_type_property, ComplexType: build_functions.build_complex_type, EntityType: build_functions.build_entity_type, - EnumType: build_functions.build_enum_type, EntitySet: build_functions.build_entity_set, EndRole: build_functions_v2.build_end_role, ReferentialConstraint: build_functions_v2.build_referential_constraint, diff --git a/pyodata/v2/build_functions.py b/pyodata/v2/build_functions.py index 7afb82ce..bdd419f1 100644 --- a/pyodata/v2/build_functions.py +++ b/pyodata/v2/build_functions.py @@ -8,9 +8,9 @@ from typing import List from pyodata.config import Config -from pyodata.exceptions import PyODataParserError, PyODataModelError -from pyodata.model.elements import EntityType, ComplexType, EnumType, NullType, build_element, \ - EntitySet, FunctionImport, ExternalAnnotation, Annotation, Typ, Identifier, Types +from pyodata.exceptions import PyODataModelError +from pyodata.model.elements import EntityType, ComplexType, NullType, build_element, EntitySet, FunctionImport, \ + ExternalAnnotation, Annotation, Typ, Identifier, Types from pyodata.policies import ParserError from pyodata.v2.elements import AssociationSetEndRole, Association, AssociationSet, NavigationTypeProperty, EndRole, \ Schema, NullAssociation, ReferentialConstraint, PrincipalRole, DependentRole @@ -37,15 +37,6 @@ def build_schema(config: Config, schema_nodes): decl = Schema.Declaration(namespace) schema._decls[namespace] = decl - for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces): - try: - etype = build_element(EnumType, config, type_node=enum_type, namespace=namespace) - except (PyODataParserError, AttributeError) as ex: - config.err_policy(ParserError.ENUM_TYPE).resolve(ex) - etype = NullType(enum_type.get('Name')) - - decl.add_enum_type(etype) - for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): try: ctype = build_element(ComplexType, config, type_node=complex_type) diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py index 5d1812c6..583c7c08 100644 --- a/pyodata/v4/__init__.py +++ b/pyodata/v4/__init__.py @@ -4,9 +4,9 @@ from pyodata.config import ODATAVersion from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits -from pyodata.model.elements import Typ, Schema, EnumType, ComplexType, StructType, StructTypeProperty, EntityType +from pyodata.model.elements import Typ, Schema, ComplexType, StructType, StructTypeProperty, EntityType -from pyodata.v4.elements import NavigationTypeProperty +from pyodata.v4.elements import NavigationTypeProperty, EnumType from pyodata.v4.type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration @@ -23,7 +23,7 @@ def build_functions(): StructTypeProperty: build_functions.build_struct_type_property, StructType: build_functions.build_struct_type, NavigationTypeProperty: build_functions_v4.build_navigation_type_property, - EnumType: build_functions.build_enum_type, + EnumType: build_functions_v4.build_enum_type, ComplexType: build_functions.build_complex_type, EntityType: build_functions.build_entity_type, Schema: build_functions_v4.build_schema, diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index ff2eb07d..8468995e 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -5,10 +5,9 @@ from pyodata.config import Config from pyodata.exceptions import PyODataParserError, PyODataModelError -from pyodata.model.elements import ComplexType, Schema, EnumType, NullType, build_element, EntityType, Types,\ - StructTypeProperty +from pyodata.model.elements import ComplexType, Schema, NullType, build_element, EntityType, Types, StructTypeProperty from pyodata.policies import ParserError -from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint +from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint, EnumMember, EnumType # pylint: disable=protected-access,too-many-locals,too-many-branches,too-many-statements @@ -132,3 +131,53 @@ def build_navigation_type_property(config: Config, node): partner, node.get('contains_target'), ref_cons) + + +# pylint: disable=protected-access, too-many-locals +def build_enum_type(config: Config, type_node, namespace): + ename = type_node.get('Name') + is_flags = type_node.get('IsFlags') + + # namespace = kwargs['namespace'] + + underlying_type = type_node.get('UnderlyingType') + + # https://docs.oasis-open.org/odata/odata-csdl-json/v4.01/csprd04/odata-csdl-json-v4.01-csprd04.html#sec_EnumerationType + if underlying_type is None: + underlying_type = 'Edm.Int32' + + valid_types = { + 'Edm.Byte': [0, 2 ** 8 - 1], + 'Edm.Int16': [-2 ** 15, 2 ** 15 - 1], + 'Edm.Int32': [-2 ** 31, 2 ** 31 - 1], + 'Edm.Int64': [-2 ** 63, 2 ** 63 - 1], + 'Edm.SByte': [-2 ** 7, 2 ** 7 - 1] + } + + if underlying_type not in valid_types: + raise PyODataParserError( + f'Type {underlying_type} is not valid as underlying type for EnumType - must be one of {valid_types}') + + mtype = Types.from_name(underlying_type, config) + etype = EnumType(ename, is_flags, mtype, namespace) + + members = type_node.xpath('edm:Member', namespaces=config.namespaces) + + next_value = 0 + for member in members: + name = member.get('Name') + value = member.get('Value') + + if value is not None: + next_value = int(value) + + vtype = valid_types[underlying_type] + if not vtype[0] < next_value < vtype[1]: + raise PyODataParserError(f'Value {next_value} is out of range for type {underlying_type}') + + emember = EnumMember(etype, name, next_value) + etype._member.append(emember) + + next_value += 1 + + return etype diff --git a/pyodata/v4/elements.py b/pyodata/v4/elements.py index d905afbd..9887fdc9 100644 --- a/pyodata/v4/elements.py +++ b/pyodata/v4/elements.py @@ -1,8 +1,10 @@ """ Repository of elements specific to the ODATA V4""" from typing import Optional, List -from pyodata.exceptions import PyODataModelError -from pyodata.model.elements import VariableDeclaration, StructType +from pyodata.exceptions import PyODataModelError, PyODataException +from pyodata.model.elements import VariableDeclaration, StructType, Identifier +from pyodata.model.type_traits import TypTraits +from pyodata.v4.type_traits import EnumTypTrait class NullProperty: @@ -90,3 +92,74 @@ def partner(self, value: StructType): @property def referential_constraints(self) -> List[ReferentialConstraint]: return self._referential_constraints + + +class EnumMember: + """ Represents individual enum values """ + def __init__(self, parent, name, value): + self._parent = parent + self._name = name + self._value = value + + def __str__(self): + return f"{self._parent.name}\'{self._name}\'" + + @property + def name(self): + return self._name + + @property + def value(self): + return self._value + + @property + def parent(self): + return self._parent + + +class EnumType(Identifier): + """ Represents enum type """ + def __init__(self, name, is_flags, underlying_type, namespace): + super(EnumType, self).__init__(name) + self._member = list() + self._underlying_type = underlying_type + self._traits = TypTraits() + self._namespace = namespace + + if is_flags == 'True': + self._is_flags = True + else: + self._is_flags = False + + def __str__(self): + return f"{self.__class__.__name__}({self._name})" + + def __getattr__(self, item): + member = next(filter(lambda x: x.name == item, self._member), None) + if member is None: + raise PyODataException(f'EnumType {self} has no member {item}') + + return member + + def __getitem__(self, item): + # If the item is type string then we want to check for members with that name instead + if isinstance(item, str): + return self.__getattr__(item) + + member = next(filter(lambda x: x.value == int(item), self._member), None) + if member is None: + raise PyODataException(f'EnumType {self} has no member with value {item}') + + return member + + @property + def is_flags(self): + return self._is_flags + + @property + def traits(self): + return EnumTypTrait(self) + + @property + def namespace(self): + return self._namespace diff --git a/pyodata/v4/type_traits.py b/pyodata/v4/type_traits.py index 6125be4a..49a11d2c 100644 --- a/pyodata/v4/type_traits.py +++ b/pyodata/v4/type_traits.py @@ -251,3 +251,22 @@ def from_json(self, value: str) -> 'geojson.GeoJSON': def to_json(self, value: 'geojson.GeoJSON') -> str: return geojson.dumps(value) + + +class EnumTypTrait(TypTraits): + """ EnumType type trait """ + def __init__(self, enum_type): + self._enum_type = enum_type + + def to_literal(self, value): + return f'{value.parent.namespace}.{value}' + + def from_json(self, value): + return getattr(self._enum_type, value) + + def from_literal(self, value): + # remove namespaces + enum_value = value.split('.')[-1] + # remove enum type + name = enum_value.split("'")[1] + return getattr(self._enum_type, name) diff --git a/tests/metadata.xml b/tests/metadata.xml index da460388..3df74fb4 100644 --- a/tests/metadata.xml +++ b/tests/metadata.xml @@ -114,23 +114,6 @@ - - - - - - - - - - - - - - - - - @@ -201,7 +184,6 @@ sap:topable="true"/> - diff --git a/tests/metadata_v4.xml b/tests/metadata_v4.xml index 560b409c..b49b2318 100644 --- a/tests/metadata_v4.xml +++ b/tests/metadata_v4.xml @@ -148,6 +148,19 @@ + + + + + + + + + + + + + @@ -173,6 +186,7 @@ + diff --git a/tests/test_model_v2.py b/tests/test_model_v2.py index a00ee0be..9198da3c 100644 --- a/tests/test_model_v2.py +++ b/tests/test_model_v2.py @@ -35,7 +35,6 @@ def test_edmx(schema): 'CarIDPic', 'Customer', 'Order', - 'EnumTest' } assert set((entity_set.name for entity_set in schema.entity_sets)) == { @@ -51,12 +50,6 @@ def test_edmx(schema): 'CarIDPics', 'Customers', 'Orders', - 'EnumTests' - } - - assert set((enum_type.name for enum_type in schema.enum_types)) == { - 'Country', - 'Language' } master_entity = schema.entity_type('MasterEntity') @@ -879,8 +872,6 @@ def test_null_type(xml_builder_factory): - - @@ -903,8 +894,6 @@ def test_null_type(xml_builder_factory): type_info = TypeInfo(namespace=None, name='MasterProperty', is_collection=False) assert isinstance(schema.get_type(type_info).proprty('Key').typ, NullType) - type_info = TypeInfo(namespace=None, name='MasterEnum', is_collection=False) - assert isinstance(schema.get_type(type_info), NullType) type_info = TypeInfo(namespace=None, name='MasterComplex', is_collection=False) assert isinstance(schema.get_type(type_info), NullType) @@ -1175,77 +1164,6 @@ def test_whitelisted_edm_namespace_2007_05(mocked, xml_builder_factory): mocked.assert_called_once() -def test_enum_parsing(schema): - """Test correct parsing of enum""" - - country = schema.enum_type('Country').USA - assert str(country) == "Country'USA'" - - country2 = schema.enum_type('Country')['USA'] - assert str(country2) == "Country'USA'" - - try: - schema.enum_type('Country').Cyprus - except PyODataException as ex: - assert str(ex) == f'EnumType EnumType(Country) has no member Cyprus' - - c = schema.enum_type('Country')[1] - assert str(c) == "Country'China'" - - try: - schema.enum_type('Country')[15] - except PyODataException as ex: - assert str(ex) == f'EnumType EnumType(Country) has no member with value {15}' - - type_info = TypeInfo(namespace=None, name='Country', is_collection=False) - - try: - schema.get_type(type_info) - except PyODataModelError as ex: - assert str(ex) == f'Neither primitive types nor types parsed from service metadata contain requested type {type_info[0]}' - - language = schema.enum_type('Language') - assert language.is_flags is True - - try: - schema.enum_type('ThisEnumDoesNotExist') - except KeyError as ex: - assert str(ex) == f'\'EnumType ThisEnumDoesNotExist does not exist in any Schema Namespace\'' - - try: - schema.enum_type('Country', 'WrongNamespace').USA - except KeyError as ex: - assert str(ex) == '\'EnumType Country does not exist in Schema Namespace WrongNamespace\'' - - -def test_unsupported_enum_underlying_type(xml_builder_factory): - """Test if parser will parse only allowed underlying types""" - xml_builder = xml_builder_factory() - xml_builder.add_schema('Test', '') - xml = xml_builder.serialize() - - try: - MetadataBuilder(xml).build() - except PyODataParserError as ex: - assert str(ex).startswith(f'Type Edm.Bool is not valid as underlying type for EnumType - must be one of') - - -def test_enum_value_out_of_range(xml_builder_factory): - """Test if parser will check for values ot of range defined by underlying type""" - xml_builder = xml_builder_factory() - xml_builder.add_schema('Test', """ - - - - """) - xml = xml_builder.serialize() - - try: - MetadataBuilder(xml).build() - except BaseException as ex: - assert str(ex) == f'Value -130 is out of range for type Edm.Byte' - - @patch('logging.Logger.warning') def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_factory): """Test that correct behavior when non existing property is referenced in annotation""" diff --git a/tests/test_model_v4.py b/tests/test_model_v4.py index f108b1f7..0328d29c 100644 --- a/tests/test_model_v4.py +++ b/tests/test_model_v4.py @@ -3,15 +3,13 @@ import geojson import pytest +from pyodata.policies import PolicyIgnore from pyodata.model.builder import MetadataBuilder -from pyodata.exceptions import PyODataModelError -from pyodata.model.type_traits import TypTraits -from pyodata.model.elements import Types +from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError +from pyodata.model.elements import Types, TypeInfo, NullType from pyodata.config import Config -from pyodata.v4 import ODataV4 -from tests.conftest import metadata -from v4 import NavigationTypeProperty +from pyodata.v4 import ODataV4, NavigationTypeProperty def test_type_traits(): @@ -172,3 +170,93 @@ def test_referential_constraint(schema_v4): assert repr(nav_property.referential_constraints[0]) == \ 'ReferentialConstraint(StructTypeProperty(CategoryID), StructTypeProperty(ID))' + +def test_enum_parsing(schema_v4): + """Test correct parsing of enum""" + + country = schema_v4.enum_type('Country').USA + assert str(country) == "Country'USA'" + + country2 = schema_v4.enum_type('Country')['USA'] + assert str(country2) == "Country'USA'" + + try: + schema_v4.enum_type('Country').Cyprus + except PyODataException as ex: + assert str(ex) == f'EnumType EnumType(Country) has no member Cyprus' + + c = schema_v4.enum_type('Country')[1] + assert str(c) == "Country'China'" + + try: + schema_v4.enum_type('Country')[15] + except PyODataException as ex: + assert str(ex) == f'EnumType EnumType(Country) has no member with value {15}' + + type_info = TypeInfo(namespace=None, name='Country', is_collection=False) + + try: + schema_v4.get_type(type_info) + except PyODataModelError as ex: + assert str(ex) == f'Neither primitive types nor types parsed from service metadata contain requested type {type_info[0]}' + + language = schema_v4.enum_type('Language') + assert language.is_flags is True + + try: + schema_v4.enum_type('ThisEnumDoesNotExist') + except KeyError as ex: + assert str(ex) == f'\'EnumType ThisEnumDoesNotExist does not exist in any Schema Namespace\'' + + try: + schema_v4.enum_type('Country', 'WrongNamespace').USA + except KeyError as ex: + assert str(ex) == '\'EnumType Country does not exist in Schema Namespace WrongNamespace\'' + + +def test_unsupported_enum_underlying_type(xml_builder_factory): + """Test if parser will parse only allowed underlying types""" + xml_builder = xml_builder_factory() + xml_builder.add_schema('Test', '') + xml = xml_builder.serialize() + + try: + MetadataBuilder(xml, Config(ODataV4)).build() + except PyODataParserError as ex: + assert str(ex).startswith(f'Type Edm.Bool is not valid as underlying type for EnumType - must be one of') + + +def test_enum_value_out_of_range(xml_builder_factory): + """Test if parser will check for values ot of range defined by underlying type""" + xml_builder = xml_builder_factory() + xml_builder.add_schema('Test', """ + + + + """) + xml = xml_builder.serialize() + + try: + MetadataBuilder(xml, Config(ODataV4)).build() + except BaseException as ex: + assert str(ex) == f'Value -130 is out of range for type Edm.Byte' + + +def test_enum_null_type(xml_builder_factory): + """ Test NullType being correctly assigned to invalid types""" + xml_builder = xml_builder_factory() + xml_builder.add_schema('TEST.NAMESPACE', """ + + """) + + metadata = MetadataBuilder( + xml_builder.serialize(), + config=Config( + ODataV4, + default_error_policy=PolicyIgnore() + )) + + schema = metadata.build() + + type_info = TypeInfo(namespace=None, name='MasterEnum', is_collection=False) + assert isinstance(schema.get_type(type_info), NullType) \ No newline at end of file diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index ff82288f..4348f2ef 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -86,32 +86,6 @@ def test_create_entity_code_400(service): assert str(e_info.value).startswith('HTTP POST for Entity Set') -@responses.activate -def test_create_entity_containing_enum(service): - """Basic test on creating entity with enum""" - - # pylint: disable=redefined-outer-name - - responses.add( - responses.POST, - "{0}/EnumTests".format(service.url), - headers={'Content-type': 'application/json'}, - json={'d': { - 'CountryOfOrigin': 'USA', - }}, - status=201) - - result = service.entity_sets.EnumTests.create_entity().set(**{'CountryOfOrigin': 'USA'}).execute() - - USA = service.schema.enum_type('Country').USA - assert result.CountryOfOrigin == USA - - traits = service.schema.enum_type('Country').traits - literal = traits.to_literal(USA) - - assert literal == "EXAMPLE_SRV.Country\'USA\'" - assert traits.from_literal(literal).name == 'USA' - @responses.activate def test_create_entity_nested(service): """Basic test on creating entity""" From 5ab911360994be31b0cd30e78514606e6c7d4350 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Wed, 30 Oct 2019 17:16:57 +0100 Subject: [PATCH 09/36] Add support for EntitySet in OData V4 --- CHANGELOG.md | 1 + pyodata/model/build_functions.py | 11 +++++ pyodata/model/elements.py | 5 ++- pyodata/policies.py | 2 + pyodata/v4/__init__.py | 4 +- pyodata/v4/build_functions.py | 36 +++++++++++++-- pyodata/v4/elements.py | 75 +++++++++++++++++++++++++++++++- tests/test_model_v4.py | 69 ++++++++++++++++++++++++++++- 8 files changed, 194 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a823e8..58ce2102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Separate type repositories for individual versions of OData - Martin Miksik - Support for OData V4 primitive types - Martin Miksik - Support for navigation property in OData v4 - Martin Miksik +- Support for EntitySet in OData v4 - Martin Miksik ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik diff --git a/pyodata/model/build_functions.py b/pyodata/model/build_functions.py index 78fb545d..4507083e 100644 --- a/pyodata/model/build_functions.py +++ b/pyodata/model/build_functions.py @@ -10,6 +10,9 @@ Types, EntitySet, ValueHelper, ValueHelperParameter, FunctionImportParameter, \ FunctionImport, metadata_attribute_get, EntityType, ComplexType, Annotation, build_element +from pyodata.v4 import ODataV4 +import pyodata.v4.elements as v4 + def modlog(): return logging.getLogger("callbacks") @@ -103,6 +106,10 @@ def build_entity_set(config, entity_set_node): name = entity_set_node.get('Name') et_info = Types.parse_type_name(entity_set_node.get('EntityType')) + nav_prop_bins = [] + for nav_prop_bin in entity_set_node.xpath('edm:NavigationPropertyBinding', namespaces=config.namespaces): + nav_prop_bins.append(build_element('NavigationPropertyBinding', config, node=nav_prop_bin, et_info=et_info)) + # TODO: create a class SAP attributes addressable = sap_attribute_get_bool(entity_set_node, 'addressable', True) creatable = sap_attribute_get_bool(entity_set_node, 'creatable', True) @@ -115,6 +122,10 @@ def build_entity_set(config, entity_set_node): req_filter = sap_attribute_get_bool(entity_set_node, 'requires-filter', False) label = sap_attribute_get_string(entity_set_node, 'label') + if config.odata_version == ODataV4: + return v4.EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, + topable, req_filter, label, nav_prop_bins) + return EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, topable, req_filter, label) diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index e0a4cec8..18b8f4c1 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -590,7 +590,10 @@ def nav_proprties(self): return list(self._nav_properties.values()) def nav_proprty(self, property_name): - return self._nav_properties[property_name] + try: + return self._nav_properties[property_name] + except KeyError as ex: + raise PyODataModelError(f'{self} does not contain navigation property {property_name}') from ex class EntitySet(Identifier): diff --git a/pyodata/policies.py b/pyodata/policies.py index cb76a768..5f74ad8a 100644 --- a/pyodata/policies.py +++ b/pyodata/policies.py @@ -12,11 +12,13 @@ class ParserError(Enum): """ Represents all the different errors the parser is able to deal with.""" PROPERTY = auto() NAVIGATION_PROPERTY = auto() + NAVIGATION_PROPERTY_BIDING = auto() ANNOTATION = auto() ASSOCIATION = auto() ENUM_TYPE = auto() ENTITY_TYPE = auto() + ENTITY_SET = auto() COMPLEX_TYPE = auto() REFERENTIAL_CONSTRAINT = auto() diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py index 583c7c08..9e6d7ee0 100644 --- a/pyodata/v4/__init__.py +++ b/pyodata/v4/__init__.py @@ -6,7 +6,7 @@ from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits from pyodata.model.elements import Typ, Schema, ComplexType, StructType, StructTypeProperty, EntityType -from pyodata.v4.elements import NavigationTypeProperty, EnumType +from pyodata.v4.elements import NavigationTypeProperty, NavigationPropertyBinding, EntitySet, EnumType from pyodata.v4.type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration @@ -23,9 +23,11 @@ def build_functions(): StructTypeProperty: build_functions.build_struct_type_property, StructType: build_functions.build_struct_type, NavigationTypeProperty: build_functions_v4.build_navigation_type_property, + NavigationPropertyBinding: build_functions_v4.build_navigation_property_binding, EnumType: build_functions_v4.build_enum_type, ComplexType: build_functions.build_complex_type, EntityType: build_functions.build_entity_type, + EntitySet: build_functions.build_entity_set, Schema: build_functions_v4.build_schema, } diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index 8468995e..c73ef328 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -7,7 +7,8 @@ from pyodata.exceptions import PyODataParserError, PyODataModelError from pyodata.model.elements import ComplexType, Schema, NullType, build_element, EntityType, Types, StructTypeProperty from pyodata.policies import ParserError -from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint, EnumMember, EnumType +from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint,\ + NavigationPropertyBinding, to_path_info, EntitySet, EnumMember, EnumType # pylint: disable=protected-access,too-many-locals,too-many-branches,too-many-statements @@ -110,10 +111,33 @@ def build_schema(config: Config, schema_nodes): ref_con.proprty = proprty ref_con.referenced_proprty = referenced_proprty - # TODO: Then, process Associations nodes because they refer EntityTypes and they are referenced by AssociationSets. - # TODO: Then, process EntitySet, FunctionImport and AssociationSet nodes. - # TODO: Finally, process Annotation nodes when all Scheme nodes are completely processed. + # Process entity sets + for schema_node in schema_nodes: + namespace = schema_node.get('Namespace') + decl = schema._decls[namespace] + for entity_set in schema_node.xpath('edm:EntityContainer/edm:EntitySet', namespaces=config.namespaces): + try: + eset = build_element(EntitySet, config, entity_set_node=entity_set) + eset.entity_type = schema.entity_type(eset.entity_type_info[1], namespace=eset.entity_type_info[0]) + decl.entity_sets[eset.name] = eset + except (PyODataParserError, KeyError) as ex: + config.err_policy(ParserError.ENTITY_SET).resolve(ex) + + # After all entity sets are parsed resolve the individual bindings among them and entity types + for entity_set in schema.entity_sets: + for nav_prop_bin in entity_set.navigation_property_bindings: + path_info = nav_prop_bin.path_info + try: + nav_prop_bin.path = schema.entity_type(path_info.type, + namespace=path_info.namespace).nav_proprty(path_info.proprty) + nav_prop_bin.target = schema.entity_set(nav_prop_bin.target_info) + except (PyODataModelError, KeyError) as ex: + config.err_policy(ParserError.NAVIGATION_PROPERTY_BIDING).resolve(ex) + nav_prop_bin.path = NullType(path_info.type) + nav_prop_bin.target = NullProperty(nav_prop_bin.target_info) + + # TODO: Finally, process Annotation nodes when all Scheme nodes are completely processed. return schema @@ -133,6 +157,10 @@ def build_navigation_type_property(config: Config, node): ref_cons) +def build_navigation_property_binding(config: Config, node, et_info): + return NavigationPropertyBinding(to_path_info(node.get('Path'), et_info), node.get('Target')) + + # pylint: disable=protected-access, too-many-locals def build_enum_type(config: Config, type_node, namespace): ename = type_node.get('Name') diff --git a/pyodata/v4/elements.py b/pyodata/v4/elements.py index 9887fdc9..95b0ce29 100644 --- a/pyodata/v4/elements.py +++ b/pyodata/v4/elements.py @@ -1,11 +1,26 @@ """ Repository of elements specific to the ODATA V4""" from typing import Optional, List +import collections + +from pyodata.model import elements from pyodata.exceptions import PyODataModelError, PyODataException -from pyodata.model.elements import VariableDeclaration, StructType, Identifier +from pyodata.model.elements import VariableDeclaration, StructType, TypeInfo, Identifier from pyodata.model.type_traits import TypTraits from pyodata.v4.type_traits import EnumTypTrait +PathInfo = collections.namedtuple('PathInfo', 'namespace type proprty') + + +def to_path_info(value: str, et_info: TypeInfo): + """ Helper function for parsing Path attribute on NavigationPropertyBinding property """ + if '/' in value: + parts = value.split('.') + entity_name, property_name = parts[-1].split('/') + return PathInfo('.'.join(parts[:-1]), entity_name, property_name) + else: + return PathInfo(et_info.namespace, et_info.name, value) + class NullProperty: """ Defines fallback class when parser is unable to process property defined in xml """ @@ -94,6 +109,64 @@ def referential_constraints(self) -> List[ReferentialConstraint]: return self._referential_constraints +class NavigationPropertyBinding: + """ Describes which entity set of navigation property contains related entities + https://docs.oasis-open.org/odata/odata-csdl-xml/v4.01/csprd06/odata-csdl-xml-v4.01-csprd06.html#sec_NavigationPropertyBinding + """ + + def __init__(self, path_info: PathInfo, target_info: str): + self._path_info = path_info + self._target_info = target_info + self._path: Optional[NavigationTypeProperty] = None + self._target: Optional['EntitySet'] = None + + def __repr__(self): + return f"{self.__class__.__name__}({self.path}, {self.target})" + + def __str__(self): + return f"{self.__class__.__name__}({self.path}, {self.target})" + + @property + def path_info(self) -> PathInfo: + return self._path_info + + @property + def target_info(self): + return self._target_info + + @property + def path(self) -> Optional[NavigationTypeProperty]: + return self._path + + @path.setter + def path(self, value: NavigationTypeProperty): + self._path = value + + @property + def target(self) -> Optional['EntitySet']: + return self._target + + @target.setter + def target(self, value: 'EntitySet'): + self._target = value + + +class EntitySet(elements.EntitySet): + """ EntitySet complaint with OData V4 + https://docs.oasis-open.org/odata/odata-csdl-xml/v4.01/csprd06/odata-csdl-xml-v4.01-csprd06.html#sec_EntitySet + """ + def __init__(self, name, entity_type_info, addressable, creatable, updatable, deletable, searchable, countable, + pageable, topable, req_filter, label, navigation_property_bindings): + super(EntitySet, self).__init__(name, entity_type_info, addressable, creatable, updatable, deletable, + searchable, countable, pageable, topable, req_filter, label) + + self._navigation_property_bindings = navigation_property_bindings + + @property + def navigation_property_bindings(self) -> List[NavigationPropertyBinding]: + return self._navigation_property_bindings + + class EnumMember: """ Represents individual enum values """ def __init__(self, parent, name, value): diff --git a/tests/test_model_v4.py b/tests/test_model_v4.py index 0328d29c..d4300dad 100644 --- a/tests/test_model_v4.py +++ b/tests/test_model_v4.py @@ -3,12 +3,14 @@ import geojson import pytest -from pyodata.policies import PolicyIgnore +from pyodata.policies import PolicyIgnore, ParserError from pyodata.model.builder import MetadataBuilder from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError -from pyodata.model.elements import Types, TypeInfo, NullType +from pyodata.model.elements import Types, TypeInfo, Schema, NullType from pyodata.config import Config +from tests.conftest import metadata +from pyodata.v4.elements import NavigationTypeProperty, EntitySet, NavigationPropertyBinding from pyodata.v4 import ODataV4, NavigationTypeProperty @@ -171,6 +173,69 @@ def test_referential_constraint(schema_v4): 'ReferentialConstraint(StructTypeProperty(CategoryID), StructTypeProperty(ID))' +def test_navigation_property_binding(schema_v4: Schema): + """Test parsing of navigation property bindings on EntitySets""" + eset: EntitySet = schema_v4.entity_set('People') + assert str(eset) == 'EntitySet(People)' + + nav_prop_biding: NavigationPropertyBinding = eset.navigation_property_bindings[0] + assert repr(nav_prop_biding) == "NavigationPropertyBinding(NavigationTypeProperty(Friends), EntitySet(People))" + + +def test_invalid_property_binding_on_entity_set(xml_builder_factory): + """Test parsing of invalid property bindings on EntitySets""" + schema = """ + + + + + + + + + """ + + etype, path, target = 'MightySchema.Person', 'Friends', 'People' + + xml_builder = xml_builder_factory() + xml_builder.add_schema('MightySchema', schema.format(etype, 'Mistake', target)) + xml = xml_builder.serialize() + + with pytest.raises(PyODataModelError) as ex_info: + MetadataBuilder(xml, Config(ODataV4)).build() + assert ex_info.value.args[0] == 'EntityType(Person) does not contain navigation property Mistake' + + try: + MetadataBuilder(xml, Config(ODataV4, custom_error_policies={ + ParserError.NAVIGATION_PROPERTY_BIDING: PolicyIgnore() + })).build() + except BaseException as ex: + raise pytest.fail(f'IgnorePolicy was supposed to silence "{ex}" but it did not.') + + xml_builder = xml_builder_factory() + xml_builder.add_schema('MightySchema', schema.format('Mistake', path, target)) + xml = xml_builder.serialize() + + with pytest.raises(KeyError) as ex_info: + MetadataBuilder(xml, Config(ODataV4)).build() + assert ex_info.value.args[0] == 'EntityType Mistake does not exist in any Schema Namespace' + + try: + MetadataBuilder(xml, Config(ODataV4, custom_error_policies={ + ParserError.ENTITY_SET: PolicyIgnore() + })).build() + except BaseException as ex: + raise pytest.fail(f'IgnorePolicy was supposed to silence "{ex}" but it did not.') + + xml_builder = xml_builder_factory() + xml_builder.add_schema('MightySchema', schema.format(etype, path, 'Mistake')) + xml = xml_builder.serialize() + + with pytest.raises(KeyError) as ex_info: + MetadataBuilder(xml, Config(ODataV4)).build() + assert ex_info.value.args[0] == 'EntitySet Mistake does not exist in any Schema Namespace' + + def test_enum_parsing(schema_v4): """Test correct parsing of enum""" From 9c4751475bbc155ab0ddde568449e88a8f8143fe Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 1 Nov 2019 15:57:39 +0100 Subject: [PATCH 10/36] Add support for TypeDefinition in OData V4 TypeDefinitions are simply aliases for primitive types. They can be annotated. Thus, reimplementation of annotation for V4 is included in this commit. Children of ODataVersion have to specify which annotations are supported and how they should be processed. Annotation are parsed using function 'build_annotation'. As annotation are always tied to specific element and there is no centralized repository of annotations this function must return void. http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part3-csdl/odata-v4.0-errata03-os-part3-csdl-complete.html#_Toc453752574 --- CHANGELOG.md | 1 + pyodata/config.py | 17 +++-- pyodata/model/build_functions.py | 68 +++++++++--------- pyodata/model/builder.py | 24 +++---- pyodata/model/elements.py | 116 +++++++++++++++++++++++++++---- pyodata/v2/__init__.py | 13 ++-- pyodata/v2/build_functions.py | 44 ++++-------- pyodata/v4/__init__.py | 9 ++- pyodata/v4/build_functions.py | 31 +++++++-- pyodata/v4/elements.py | 22 +++++- tests/metadata_v4.xml | 6 ++ tests/test_model.py | 4 ++ tests/test_model_v4.py | 18 +++-- 13 files changed, 258 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ce2102..d82a793c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Support for OData V4 primitive types - Martin Miksik - Support for navigation property in OData v4 - Martin Miksik - Support for EntitySet in OData v4 - Martin Miksik +- Support for TypeDefinition in OData v4 - Martin Miksik ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik diff --git a/pyodata/config.py b/pyodata/config.py index 00ab6601..38a83dd8 100644 --- a/pyodata/config.py +++ b/pyodata/config.py @@ -7,7 +7,7 @@ # pylint: disable=cyclic-import if TYPE_CHECKING: - from pyodata.model.elements import Typ # noqa + from pyodata.model.elements import Typ, Annotation # noqa class ODATAVersion(ABC): @@ -32,6 +32,11 @@ def primitive_types() -> List['Typ']: def build_functions() -> Dict[type, Callable]: """ Here we define which elements are supported and what is their python representation""" + @staticmethod + @abstractmethod + def annotations() -> Dict['Annotation', Callable]: + """ Here we define which annotations are supported and what is their python representation""" + class Config: # pylint: disable=too-many-instance-attributes,missing-docstring @@ -73,8 +78,8 @@ def __init__(self, self._odata_version = odata_version self._sap_value_helper_directions = None - self._sap_annotation_value_list = None self._annotation_namespaces = None + self._aliases: Dict[str, str] = dict() def err_policy(self, error: ParserError) -> ErrorPolicy: """ Returns error policy for given error. If custom error policy fo error is set, then returns that.""" @@ -111,8 +116,12 @@ def sap_value_helper_directions(self): return self._sap_value_helper_directions @property - def sap_annotation_value_list(self): - return self._sap_annotation_value_list + def aliases(self) -> Dict[str, str]: + return self._aliases + + @aliases.setter + def aliases(self, value: Dict[str, str]): + self._aliases = value @property def annotation_namespace(self): diff --git a/pyodata/model/build_functions.py b/pyodata/model/build_functions.py index 4507083e..8c06e352 100644 --- a/pyodata/model/build_functions.py +++ b/pyodata/model/build_functions.py @@ -4,14 +4,16 @@ import copy import logging +from pyodata.policies import ParserError from pyodata.config import Config -from pyodata.exceptions import PyODataParserError +from pyodata.exceptions import PyODataParserError, PyODataModelError from pyodata.model.elements import sap_attribute_get_bool, sap_attribute_get_string, StructType, StructTypeProperty, \ Types, EntitySet, ValueHelper, ValueHelperParameter, FunctionImportParameter, \ - FunctionImport, metadata_attribute_get, EntityType, ComplexType, Annotation, build_element + FunctionImport, metadata_attribute_get, EntityType, ComplexType, build_element -from pyodata.v4 import ODataV4 -import pyodata.v4.elements as v4 +# pylint: disable=cyclic-import +# When using `import xxx as yyy` it is not a problem and we need this dependency +import pyodata.v4 as v4 def modlog(): @@ -122,39 +124,15 @@ def build_entity_set(config, entity_set_node): req_filter = sap_attribute_get_bool(entity_set_node, 'requires-filter', False) label = sap_attribute_get_string(entity_set_node, 'label') - if config.odata_version == ODataV4: - return v4.EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, - topable, req_filter, label, nav_prop_bins) + if config.odata_version == v4.ODataV4: + return v4.EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, + pageable, topable, req_filter, label, nav_prop_bins) return EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, topable, req_filter, label) -def build_external_annotation(config, annotations_node): - target = annotations_node.get('Target') - - if annotations_node.get('Qualifier'): - modlog().warning('Ignoring qualified Annotations of %s', target) - return - - for annotation in annotations_node.xpath('edm:Annotation', namespaces=config.annotation_namespace): - annot = build_element(Annotation, config, target=target, annotation_node=annotation) - if annot is None: - continue - yield annot - - -def build_annotation(config, target, annotation_node): - term = annotation_node.get('Term') - - if term in config.sap_annotation_value_list: - return build_element(ValueHelper, config, target=target, annotation_node=annotation_node) - - modlog().warning('Unsupported Annotation( %s )', term) - return None - - -def build_value_helper(config, target, annotation_node): +def build_value_helper(config, target, annotation_node, schema): label = None collection_path = None search_supported = False @@ -179,7 +157,31 @@ def build_value_helper(config, target, annotation_node): param.value_helper = value_helper value_helper._parameters.append(param) - return value_helper + try: + try: + value_helper.entity_set = schema.entity_set( + value_helper.collection_path, namespace=value_helper.element_namespace) + except KeyError: + raise RuntimeError(f'Entity Set {value_helper.collection_path} ' + f'for {value_helper} does not exist') + + try: + vh_type = schema.typ(value_helper.proprty_entity_type_name, + namespace=value_helper.element_namespace) + except KeyError: + raise RuntimeError(f'Target Type {value_helper.proprty_entity_type_name} ' + f'of {value_helper} does not exist') + + try: + target_proprty = vh_type.proprty(value_helper.proprty_name) + except KeyError: + raise RuntimeError(f'Target Property {value_helper.proprty_name} ' + f'of {vh_type} as defined in {value_helper} does not exist') + + value_helper.proprty = target_proprty + target_proprty.value_helper = value_helper + except (RuntimeError, PyODataModelError) as ex: + config.err_policy(ParserError.ANNOTATION).resolve(ex) def build_value_helper_parameter(config, value_help_parameter_node): diff --git a/pyodata/model/builder.py b/pyodata/model/builder.py index 5ef72eb3..cf0fed85 100644 --- a/pyodata/model/builder.py +++ b/pyodata/model/builder.py @@ -1,6 +1,5 @@ """Metadata Builder Implementation""" -import collections import io from lxml import etree @@ -24,9 +23,6 @@ } -SAP_ANNOTATION_VALUE_LIST = ['com.sap.vocabularies.Common.v1.ValueList'] - - # pylint: disable=protected-access class MetadataBuilder: """Metadata builder""" @@ -99,7 +95,6 @@ def build(self): self._config.namespaces = namespaces self._config._sap_value_helper_directions = SAP_VALUE_HELPER_DIRECTIONS - self._config._sap_annotation_value_list = SAP_ANNOTATION_VALUE_LIST self._config._annotation_namespaces = ANNOTATION_NAMESPACES self.update_alias(self.get_aliases(xml, self._config), self._config) @@ -111,7 +106,8 @@ def build(self): def get_aliases(edmx, config: Config): """Get all aliases""" - aliases = collections.defaultdict(set) + # aliases = collections.defaultdict(set) + aliases = {} edm_root = edmx.xpath('/edmx:Edmx', namespaces=config.namespaces) if edm_root: edm_ref_includes = edm_root[0].xpath('edmx:Reference/edmx:Include', namespaces=config.annotation_namespace) @@ -119,23 +115,23 @@ def get_aliases(edmx, config: Config): namespace = ref_incl.get('Namespace') alias = ref_incl.get('Alias') if namespace is not None and alias is not None: - aliases[namespace].add(alias) + aliases[alias] = namespace + # aliases[namespace].add(alias) return aliases @staticmethod def update_alias(aliases, config: Config): """Update config with aliases""" - - namespace, suffix = config.sap_annotation_value_list[0].rsplit('.', 1) - config._sap_annotation_value_list.extend([alias + '.' + suffix for alias in aliases[namespace]]) - + config.aliases = aliases helper_direction_keys = list(config.sap_value_helper_directions.keys()) + for direction_key in helper_direction_keys: namespace, suffix = direction_key.rsplit('.', 1) - for alias in aliases[namespace]: - config._sap_value_helper_directions[alias + '.' + suffix] = \ - config.sap_value_helper_directions[direction_key] + for alias, alias_namespace in aliases.items(): + if alias_namespace == namespace: + config._sap_value_helper_directions[alias + '.' + suffix] = \ + config.sap_value_helper_directions[direction_key] def schema_from_xml(metadata_xml, namespaces=None): diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 18b8f4c1..504e04bd 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -3,9 +3,11 @@ import collections import itertools import logging +from abc import abstractmethod from enum import Enum from typing import Union +from pyodata.policies import ParserError from pyodata.config import Config from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError @@ -49,6 +51,40 @@ def build_element(element_name: Union[str, type], config: Config, **kwargs): raise PyODataParserError(f'{element_name} is unsupported in {config.odata_version.__name__}') +def build_annotation(term: str, config: Config, **kwargs): + """ + Similarly to build_element this function purpoas is to resolve build function for annotations. There are two + main differences: + 1) This method accepts child of Annotation. Every child has to implement static method term() -> str + + 2) Annotation has to have specified target. This target is reference to type, property and so on, because of + that there is no repository of annotations in schema. Thus this method does return void, but it might have + side effect. + # http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part3-csdl/odata-v4.0-errata03-os-part3-csdl-complete.html#_Toc453752619 + + :param term: Term defines what does the annotation do. Specification advise clients to ignore unknown terms + by default. + :param config: Config + :param kwargs: Any arguments that are to be passed to the build function e. g. etree, schema... + + :return: void + """ + + annotations = config.odata_version.annotations() + try: + for annotation in annotations: + alias, element = term.rsplit('.', 1) + namespace = config.aliases.get(alias, '') + + if term == annotation.term() or f'{namespace}.{element}' == annotation.term(): + annotations[annotation](config, **kwargs) + return + + raise PyODataParserError(f'Annotation with term {term} is unsupported in {config.odata_version.__name__}') + except PyODataException as ex: + config.err_policy(ParserError.ANNOTATION).resolve(ex) + + class NullType: def __init__(self, name): self.name = name @@ -58,6 +94,15 @@ def __getattr__(self, item): f'xml({self.name}) was not found, therefore it has been replaced with NullType.') +class NullAnnotation: + def __init__(self, term): + self.term = term + + def __getattr__(self, item): + raise PyODataModelError(f'Cannot access this annotation. An error occurred during parsing ' + f'annotation(term = {self.term}), therefore it has been replaced with NullAnnotation.') + + class Identifier: def __init__(self, name): super(Identifier, self).__init__() @@ -110,7 +155,7 @@ def register_type(typ: 'Typ', config: Config): o_version.Types[collection_name] = collection_typ @staticmethod - def from_name(name, config: Config): + def from_name(name, config: Config) -> 'Typ': o_version = config.odata_version # build types hierarchy on first use (lazy creation) @@ -160,6 +205,7 @@ def __init__(self, name, null_value, traits=TypTraits(), kind=None): self._null_value = null_value self._kind = kind if kind is not None else Typ.Kinds.Primitive # no way how to us enum value for parameter default value self._traits = traits + self._annotation = None @property def null_value(self): @@ -177,6 +223,19 @@ def is_collection(self): def kind(self): return self._kind + @property + def annotation(self) -> 'Annotation': + return self._annotation + + @annotation.setter + def annotation(self, value: 'Annotation'): + self._annotation = value + + # pylint: disable=no-member + @Identifier.name.setter + def name(self, value: str): + self._name = value + class Collection(Typ): """Represents collection items""" @@ -297,6 +356,7 @@ def __init__(self, namespace): self.function_imports = dict() self.associations = dict() self.association_sets = dict() + self.type_definitions: [str, Typ] = dict() def list_entity_types(self): return list(self.entity_types.values()) @@ -319,6 +379,9 @@ def list_associations(self): def list_association_sets(self): return list(self.association_sets.values()) + def list_type_definitions(self): + return list(self.type_definitions.values()) + def add_entity_type(self, etype): """Add new type to the type repository as well as its collection variant""" @@ -347,6 +410,10 @@ def add_enum_type(self, etype): """Add new enum type to the type repository""" self.enum_types[etype.name] = etype + def add_type_definition(self, tdefinition: Typ): + """Add new type definition to the type repository""" + self.type_definitions[tdefinition.name] = tdefinition + class Declarations(dict): def __getitem__(self, key): @@ -430,6 +497,21 @@ def enum_type(self, type_name, namespace=None): raise KeyError(f'EnumType {type_name} does not exist in any Schema Namespace') + def type_definition(self, name, namespace=None): + if namespace is not None: + try: + return self._decls[namespace].type_definitions[name] + except KeyError: + raise KeyError(f'EnumType {name} does not exist in Schema Namespace {namespace}') + + for decl in list(self._decls.values()): + try: + return decl.type_definitions[name] + except KeyError: + pass + + raise KeyError(f'EnumType {name} does not exist in any Schema Namespace') + def get_type(self, type_info): # construct search name based on collection information @@ -441,6 +523,12 @@ def get_type(self, type_info): except KeyError: pass + # then look for type in type definitions + try: + return self.type_definition(search_name, type_info.namespace) + except KeyError: + pass + # then look for type in entity types try: return self.entity_type(search_name, type_info.namespace) @@ -800,19 +888,22 @@ def value_helper(self, value): self._value_helper = value -class Annotation(): - Kinds = Enum('Kinds', 'ValueHelper') +class Annotation: - def __init__(self, kind, target, qualifier=None): + def __init__(self, target, qualifier=None): super(Annotation, self).__init__() - self._kind = kind self._element_namespace, self._element = target.split('.') self._qualifier = qualifier def __str__(self): return "{0}({1})".format(self.__class__.__name__, self.target) + @staticmethod + @abstractmethod + def term() -> str: + pass + @property def element_namespace(self): return self._element_namespace @@ -825,22 +916,13 @@ def element(self): def target(self): return '{0}.{1}'.format(self._element_namespace, self._element) - @property - def kind(self): - return self._kind - - -# pylint: disable=too-few-public-methods -class ExternalAnnotation(): - pass - class ValueHelper(Annotation): def __init__(self, target, collection_path, label, search_supported): # pylint: disable=unused-argument - super(ValueHelper, self).__init__(Annotation.Kinds.ValueHelper, target) + super(ValueHelper, self).__init__(target) self._entity_type_name, self._proprty_name = self.element.split('/') self._proprty = None @@ -854,6 +936,10 @@ def __init__(self, target, collection_path, label, search_supported): def __str__(self): return "{0}({1})".format(self.__class__.__name__, self.element) + @staticmethod + def term() -> str: + return 'com.sap.vocabularies.Common.v1.ValueList' + @property def proprty_name(self): return self._proprty_name diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index 27d8af47..712f17c6 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -11,8 +11,8 @@ from pyodata.v2.elements import NavigationTypeProperty, EndRole, Association, AssociationSetEndRole, AssociationSet, \ ReferentialConstraint, Schema -from pyodata.model.elements import StructTypeProperty, StructType, ComplexType, EntityType, EntitySet, \ - ExternalAnnotation, Annotation, ValueHelper, ValueHelperParameter, FunctionImport, Typ +from pyodata.model.elements import StructTypeProperty, StructType, ComplexType, EntityType, EntitySet, ValueHelper, \ + ValueHelperParameter, FunctionImport, Typ import pyodata.v2.build_functions as build_functions_v2 @@ -41,9 +41,6 @@ def build_functions(): Association: build_functions_v2.build_association, AssociationSetEndRole: build_functions_v2.build_association_set_end_role, AssociationSet: build_functions_v2.build_association_set, - ExternalAnnotation: build_functions.build_external_annotation, - Annotation: build_functions.build_annotation, - ValueHelper: build_functions.build_value_helper, ValueHelperParameter: build_functions.build_value_helper_parameter, FunctionImport: build_functions.build_function_import, Schema: build_functions_v2.build_schema @@ -69,3 +66,9 @@ def primitive_types() -> List[Typ]: Typ('Edm.Time', 'time\'PT00H00M\''), Typ('Edm.DateTimeOffset', 'datetimeoffset\'0000-00-00T00:00:00\'') ] + + @staticmethod + def annotations(): + return { + ValueHelper: build_functions.build_value_helper + } diff --git a/pyodata/v2/build_functions.py b/pyodata/v2/build_functions.py index bdd419f1..c50d0829 100644 --- a/pyodata/v2/build_functions.py +++ b/pyodata/v2/build_functions.py @@ -8,9 +8,9 @@ from typing import List from pyodata.config import Config -from pyodata.exceptions import PyODataModelError -from pyodata.model.elements import EntityType, ComplexType, NullType, build_element, EntitySet, FunctionImport, \ - ExternalAnnotation, Annotation, Typ, Identifier, Types +from pyodata.exceptions import PyODataParserError, PyODataModelError +from pyodata.model.elements import EntityType, ComplexType, NullType, build_element, EntitySet, FunctionImport, Typ, \ + Identifier, Types, build_annotation from pyodata.policies import ParserError from pyodata.v2.elements import AssociationSetEndRole, Association, AssociationSet, NavigationTypeProperty, EndRole, \ Schema, NullAssociation, ReferentialConstraint, PrincipalRole, DependentRole @@ -197,40 +197,20 @@ def build_schema(config: Config, schema_nodes): else: decl.association_sets[assoc_set.name] = assoc_set - # pylint: disable=too-many-nested-blocks # Finally, process Annotation nodes when all Scheme nodes are completely processed. for schema_node in schema_nodes: for annotation_group in schema_node.xpath('edm:Annotations', namespaces=config.annotation_namespace): - etree = build_element(ExternalAnnotation, config, annotations_node=annotation_group) - for annotation in etree: - if not annotation.element_namespace != schema.namespaces: - modlog().warning('%s not in the namespaces %s', annotation, ','.join(schema.namespaces)) - continue + target = annotation_group.get('Target') + if annotation_group.get('Qualifier'): + modlog().warning('Ignoring qualified Annotations of %s', target) + continue + + for annotation_node in annotation_group.xpath('edm:Annotation', namespaces=config.annotation_namespace): try: - if annotation.kind == Annotation.Kinds.ValueHelper: - try: - annotation.entity_set = schema.entity_set( - annotation.collection_path, namespace=annotation.element_namespace) - except KeyError: - raise RuntimeError(f'Entity Set {annotation.collection_path} ' - f'for {annotation} does not exist') - - try: - vh_type = schema.typ(annotation.proprty_entity_type_name, - namespace=annotation.element_namespace) - except KeyError: - raise RuntimeError(f'Target Type {annotation.proprty_entity_type_name} ' - f'of {annotation} does not exist') - - try: - target_proprty = vh_type.proprty(annotation.proprty_name) - except KeyError: - raise RuntimeError(f'Target Property {annotation.proprty_name} ' - f'of {vh_type} as defined in {annotation} does not exist') - annotation.proprty = target_proprty - target_proprty.value_helper = annotation - except (RuntimeError, PyODataModelError) as ex: + build_annotation(annotation_node.get('Term'), config, target=target, + annotation_node=annotation_node, schema=schema) + except PyODataParserError as ex: config.err_policy(ParserError.ANNOTATION).resolve(ex) return schema diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py index 9e6d7ee0..021830ef 100644 --- a/pyodata/v4/__init__.py +++ b/pyodata/v4/__init__.py @@ -6,7 +6,7 @@ from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits from pyodata.model.elements import Typ, Schema, ComplexType, StructType, StructTypeProperty, EntityType -from pyodata.v4.elements import NavigationTypeProperty, NavigationPropertyBinding, EntitySet, EnumType +from pyodata.v4.elements import NavigationTypeProperty, NavigationPropertyBinding, EntitySet, Unit, EnumType from pyodata.v4.type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration @@ -28,6 +28,7 @@ def build_functions(): ComplexType: build_functions.build_complex_type, EntityType: build_functions.build_entity_type, EntitySet: build_functions.build_entity_set, + Typ: build_functions_v4.build_type_definition, Schema: build_functions_v4.build_schema, } @@ -70,3 +71,9 @@ def primitive_types() -> List[Typ]: Typ('Edm.GeographyMultiLineString', '', GeoTypeTraits()), Typ('Edm.GeographyMultiPolygon', '', GeoTypeTraits()), ] + + @staticmethod + def annotations(): + return { + Unit: build_functions_v4.build_unit_annotation + } diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index c73ef328..43539589 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -1,14 +1,18 @@ """ Repository of build functions specific to the ODATA V4""" -# pylint: disable=missing-docstring +# pylint: disable=unused-argument, missing-docstring,invalid-name +# All methods by design of 'build_element' accept config, but no all have to use it + import itertools +import copy from pyodata.config import Config from pyodata.exceptions import PyODataParserError, PyODataModelError -from pyodata.model.elements import ComplexType, Schema, NullType, build_element, EntityType, Types, StructTypeProperty +from pyodata.model.elements import ComplexType, Schema, NullType, build_element, EntityType, Types, \ + StructTypeProperty, build_annotation, Typ from pyodata.policies import ParserError -from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint,\ - NavigationPropertyBinding, to_path_info, EntitySet, EnumMember, EnumType +from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint, \ + NavigationPropertyBinding, to_path_info, EntitySet, Unit, EnumMember, EnumType # pylint: disable=protected-access,too-many-locals,too-many-branches,too-many-statements @@ -28,6 +32,9 @@ def build_schema(config: Config, schema_nodes): decl = Schema.Declaration(namespace) schema._decls[namespace] = decl + for type_def in schema_node.xpath('edm:TypeDefinition', namespaces=config.namespaces): + decl.add_type_definition(build_element(Typ, config, node=type_def)) + for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces): try: etype = build_element(EnumType, config, type_node=enum_type, namespace=namespace) @@ -161,6 +168,22 @@ def build_navigation_property_binding(config: Config, node, et_info): return NavigationPropertyBinding(to_path_info(node.get('Path'), et_info), node.get('Target')) +def build_unit_annotation(config: Config, target: Typ, annotation_node): + target.annotation = Unit(f'self.{target.name}', annotation_node.get('String')) + + +def build_type_definition(config: Config, node): + typ = copy.deepcopy(Types.from_name(node.get('UnderlyingType'), config)) + typ.name = node.get('Name') + + annotation_nodes = node.xpath('edm:Annotation', namespaces=config.namespaces) + if annotation_nodes: + annotation_node = annotation_nodes[0] + build_annotation(annotation_node.get('Term'), config, target=typ, annotation_node=annotation_node) + + return typ + + # pylint: disable=protected-access, too-many-locals def build_enum_type(config: Config, type_node, namespace): ename = type_node.get('Name') diff --git a/pyodata/v4/elements.py b/pyodata/v4/elements.py index 95b0ce29..e45271ef 100644 --- a/pyodata/v4/elements.py +++ b/pyodata/v4/elements.py @@ -5,7 +5,7 @@ from pyodata.model import elements from pyodata.exceptions import PyODataModelError, PyODataException -from pyodata.model.elements import VariableDeclaration, StructType, TypeInfo, Identifier +from pyodata.model.elements import VariableDeclaration, StructType, TypeInfo, Annotation, Identifier from pyodata.model.type_traits import TypTraits from pyodata.v4.type_traits import EnumTypTrait @@ -18,8 +18,8 @@ def to_path_info(value: str, et_info: TypeInfo): parts = value.split('.') entity_name, property_name = parts[-1].split('/') return PathInfo('.'.join(parts[:-1]), entity_name, property_name) - else: - return PathInfo(et_info.namespace, et_info.name, value) + + return PathInfo(et_info.namespace, et_info.name, value) class NullProperty: @@ -151,6 +151,7 @@ def target(self, value: 'EntitySet'): self._target = value +# pylint: disable=too-many-arguments class EntitySet(elements.EntitySet): """ EntitySet complaint with OData V4 https://docs.oasis-open.org/odata/odata-csdl-xml/v4.01/csprd06/odata-csdl-xml-v4.01-csprd06.html#sec_EntitySet @@ -167,6 +168,21 @@ def navigation_property_bindings(self) -> List[NavigationPropertyBinding]: return self._navigation_property_bindings +class Unit(Annotation): + + def __init__(self, target, unit_name: str): + super(Unit, self).__init__(target) + self._unit_name = unit_name + + @staticmethod + def term() -> str: + return 'Org.OData.Measures.V1.Unit' + + @property + def unit_name(self) -> str: + return self._unit_name + + class EnumMember: """ Represents individual enum values """ def __init__(self, parent, name, value): diff --git a/tests/metadata_v4.xml b/tests/metadata_v4.xml index b49b2318..f8f9087a 100644 --- a/tests/metadata_v4.xml +++ b/tests/metadata_v4.xml @@ -2,6 +2,11 @@ + + + + + @@ -52,6 +57,7 @@ + diff --git a/tests/test_model.py b/tests/test_model.py index 4afadaf0..da4b6f26 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -55,6 +55,10 @@ def build_functions(): def primitive_types() -> List[Typ]: return [] + @staticmethod + def annotations(): + pass + with pytest.raises(RuntimeError) as typ_ex_info: EmptyODATA() diff --git a/tests/test_model_v4.py b/tests/test_model_v4.py index d4300dad..cfdaaea6 100644 --- a/tests/test_model_v4.py +++ b/tests/test_model_v4.py @@ -6,14 +6,13 @@ from pyodata.policies import PolicyIgnore, ParserError from pyodata.model.builder import MetadataBuilder from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError -from pyodata.model.elements import Types, TypeInfo, Schema, NullType +from pyodata.model.elements import Types, TypeInfo, Schema, NullType, EntityType from pyodata.config import Config from tests.conftest import metadata -from pyodata.v4.elements import NavigationTypeProperty, EntitySet, NavigationPropertyBinding +from pyodata.v4.elements import NavigationTypeProperty, EntitySet, NavigationPropertyBinding, Unit from pyodata.v4 import ODataV4, NavigationTypeProperty - def test_type_traits(): """Test traits""" # https://docs.oasis-open.org/odata/odata-json-format/v4.01/csprd05/odata-json-format-v4.01-csprd05.html#sec_PrimitiveValue @@ -324,4 +323,15 @@ def test_enum_null_type(xml_builder_factory): schema = metadata.build() type_info = TypeInfo(namespace=None, name='MasterEnum', is_collection=False) - assert isinstance(schema.get_type(type_info), NullType) \ No newline at end of file + assert isinstance(schema.get_type(type_info), NullType) + + +def test_type_definitions(schema_v4): + + type_info = TypeInfo(namespace=None, name='Weight', is_collection=False) + weight = schema_v4.get_type(type_info) + assert isinstance(weight.annotation, Unit) + assert weight.annotation.unit_name == 'Kilograms' + + entity: EntityType = schema_v4.entity_type('Person') + assert entity.proprty('Weight').typ == weight From 21c944d9835f2e0c4cfda4518cd590e0bf2b82fc Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 8 Nov 2019 12:58:59 +0100 Subject: [PATCH 11/36] Change implementation of struct type build functions They(EnumType, EntityType, ComplexType) now handle parsing of invalid metadata independently. Thus, you do not have to wrap build_element in try-except block. Build function either return valid type, null type or fails. (Last two options depend on which policy is set.) --- CHANGELOG.md | 1 + pyodata/model/build_functions.py | 34 ++++++++---- pyodata/v2/build_functions.py | 16 +----- pyodata/v4/build_functions.py | 94 ++++++++++++++------------------ 4 files changed, 65 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d82a793c..777b483c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik +- Build functions of struct types now handle invalid metadata independently. - Martin Miksik ### Fixed - make sure configured error policies are applied for Annotations referencing diff --git a/pyodata/model/build_functions.py b/pyodata/model/build_functions.py index 8c06e352..7e1cee55 100644 --- a/pyodata/model/build_functions.py +++ b/pyodata/model/build_functions.py @@ -6,10 +6,10 @@ from pyodata.policies import ParserError from pyodata.config import Config -from pyodata.exceptions import PyODataParserError, PyODataModelError +from pyodata.exceptions import PyODataParserError, PyODataModelError, PyODataException from pyodata.model.elements import sap_attribute_get_bool, sap_attribute_get_string, StructType, StructTypeProperty, \ Types, EntitySet, ValueHelper, ValueHelperParameter, FunctionImportParameter, \ - FunctionImport, metadata_attribute_get, EntityType, ComplexType, build_element + FunctionImport, metadata_attribute_get, EntityType, ComplexType, build_element, NullType # pylint: disable=cyclic-import # When using `import xxx as yyy` it is not a problem and we need this dependency @@ -83,28 +83,37 @@ def build_struct_type(config: Config, type_node, typ, schema=None): def build_complex_type(config: Config, type_node, schema=None): - return build_element(StructType, config, type_node=type_node, typ=ComplexType, schema=schema) + try: + return build_element(StructType, config, type_node=type_node, typ=ComplexType, schema=schema) + except (PyODataException, KeyError, AttributeError) as ex: + config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) + return NullType(type_node.get('Name')) # pylint: disable=protected-access def build_entity_type(config: Config, type_node, schema=None): - etype = build_element(StructType, config, type_node=type_node, typ=EntityType, schema=schema) + try: + etype = build_element(StructType, config, type_node=type_node, typ=EntityType, schema=schema) - for proprty in type_node.xpath('edm:Key/edm:PropertyRef', namespaces=config.namespaces): - etype._key.append(etype.proprty(proprty.get('Name'))) + for proprty in type_node.xpath('edm:Key/edm:PropertyRef', namespaces=config.namespaces): + etype._key.append(etype.proprty(proprty.get('Name'))) - for proprty in type_node.xpath('edm:NavigationProperty', namespaces=config.namespaces): - navp = build_element('NavigationTypeProperty', config, node=proprty) + for proprty in type_node.xpath('edm:NavigationProperty', namespaces=config.namespaces): + navp = build_element('NavigationTypeProperty', config, node=proprty) - if navp.name in etype._nav_properties: - raise KeyError('{0} already has navigation property {1}'.format(etype, navp.name)) + if navp.name in etype._nav_properties: + raise KeyError('{0} already has navigation property {1}'.format(etype, navp.name)) - etype._nav_properties[navp.name] = navp + etype._nav_properties[navp.name] = navp - return etype + return etype + except (KeyError, AttributeError) as ex: + config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) + return NullType(type_node.get('Name')) def build_entity_set(config, entity_set_node): + # pylint: disable=too-many-locals name = entity_set_node.get('Name') et_info = Types.parse_type_name(entity_set_node.get('EntityType')) @@ -133,6 +142,7 @@ def build_entity_set(config, entity_set_node): def build_value_helper(config, target, annotation_node, schema): + # pylint: disable=too-many-locals label = None collection_path = None search_supported = False diff --git a/pyodata/v2/build_functions.py b/pyodata/v2/build_functions.py index c50d0829..d8cbab3a 100644 --- a/pyodata/v2/build_functions.py +++ b/pyodata/v2/build_functions.py @@ -38,22 +38,10 @@ def build_schema(config: Config, schema_nodes): schema._decls[namespace] = decl for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): - try: - ctype = build_element(ComplexType, config, type_node=complex_type) - except (KeyError, AttributeError) as ex: - config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) - ctype = NullType(complex_type.get('Name')) - - decl.add_complex_type(ctype) + decl.add_complex_type(build_element(ComplexType, config, type_node=complex_type, schema=schema)) for entity_type in schema_node.xpath('edm:EntityType', namespaces=config.namespaces): - try: - etype = build_element(EntityType, config, type_node=entity_type) - except (KeyError, AttributeError) as ex: - config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) - etype = NullType(entity_type.get('Name')) - - decl.add_entity_type(etype) + decl.add_entity_type(build_element(EntityType, config, type_node=entity_type, schema=schema)) # resolve types of properties for stype in itertools.chain(schema.entity_types, schema.complex_types): diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index 43539589..5f8c8a12 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -36,31 +36,13 @@ def build_schema(config: Config, schema_nodes): decl.add_type_definition(build_element(Typ, config, node=type_def)) for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces): - try: - etype = build_element(EnumType, config, type_node=enum_type, namespace=namespace) - except (PyODataParserError, AttributeError) as ex: - config.err_policy(ParserError.ENUM_TYPE).resolve(ex) - etype = NullType(enum_type.get('Name')) - - decl.add_enum_type(etype) + decl.add_enum_type(build_element(EnumType, config, type_node=enum_type, namespace=namespace)) for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): - try: - ctype = build_element(ComplexType, config, type_node=complex_type, schema=schema) - except (KeyError, AttributeError) as ex: - config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) - ctype = NullType(complex_type.get('Name')) - - decl.add_complex_type(ctype) + decl.add_complex_type(build_element(ComplexType, config, type_node=complex_type, schema=schema)) for entity_type in schema_node.xpath('edm:EntityType', namespaces=config.namespaces): - try: - etype = build_element(EntityType, config, type_node=entity_type, schema=schema) - except (KeyError, AttributeError) as ex: - config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) - etype = NullType(entity_type.get('Name')) - - decl.add_entity_type(etype) + decl.add_entity_type(build_element(EntityType, config, type_node=entity_type, schema=schema)) # resolve types of properties for stype in itertools.chain(schema.entity_types, schema.complex_types): @@ -186,49 +168,53 @@ def build_type_definition(config: Config, node): # pylint: disable=protected-access, too-many-locals def build_enum_type(config: Config, type_node, namespace): - ename = type_node.get('Name') - is_flags = type_node.get('IsFlags') + try: + ename = type_node.get('Name') + is_flags = type_node.get('IsFlags') - # namespace = kwargs['namespace'] + # namespace = kwargs['namespace'] - underlying_type = type_node.get('UnderlyingType') + underlying_type = type_node.get('UnderlyingType') - # https://docs.oasis-open.org/odata/odata-csdl-json/v4.01/csprd04/odata-csdl-json-v4.01-csprd04.html#sec_EnumerationType - if underlying_type is None: - underlying_type = 'Edm.Int32' + # https://docs.oasis-open.org/odata/odata-csdl-json/v4.01/csprd04/odata-csdl-json-v4.01-csprd04.html#sec_EnumerationType + if underlying_type is None: + underlying_type = 'Edm.Int32' - valid_types = { - 'Edm.Byte': [0, 2 ** 8 - 1], - 'Edm.Int16': [-2 ** 15, 2 ** 15 - 1], - 'Edm.Int32': [-2 ** 31, 2 ** 31 - 1], - 'Edm.Int64': [-2 ** 63, 2 ** 63 - 1], - 'Edm.SByte': [-2 ** 7, 2 ** 7 - 1] - } + valid_types = { + 'Edm.Byte': [0, 2 ** 8 - 1], + 'Edm.Int16': [-2 ** 15, 2 ** 15 - 1], + 'Edm.Int32': [-2 ** 31, 2 ** 31 - 1], + 'Edm.Int64': [-2 ** 63, 2 ** 63 - 1], + 'Edm.SByte': [-2 ** 7, 2 ** 7 - 1] + } - if underlying_type not in valid_types: - raise PyODataParserError( - f'Type {underlying_type} is not valid as underlying type for EnumType - must be one of {valid_types}') + if underlying_type not in valid_types: + raise PyODataParserError( + f'Type {underlying_type} is not valid as underlying type for EnumType - must be one of {valid_types}') - mtype = Types.from_name(underlying_type, config) - etype = EnumType(ename, is_flags, mtype, namespace) + mtype = Types.from_name(underlying_type, config) + etype = EnumType(ename, is_flags, mtype, namespace) - members = type_node.xpath('edm:Member', namespaces=config.namespaces) + members = type_node.xpath('edm:Member', namespaces=config.namespaces) - next_value = 0 - for member in members: - name = member.get('Name') - value = member.get('Value') + next_value = 0 + for member in members: + name = member.get('Name') + value = member.get('Value') - if value is not None: - next_value = int(value) + if value is not None: + next_value = int(value) - vtype = valid_types[underlying_type] - if not vtype[0] < next_value < vtype[1]: - raise PyODataParserError(f'Value {next_value} is out of range for type {underlying_type}') + vtype = valid_types[underlying_type] + if not vtype[0] < next_value < vtype[1]: + raise PyODataParserError(f'Value {next_value} is out of range for type {underlying_type}') - emember = EnumMember(etype, name, next_value) - etype._member.append(emember) + emember = EnumMember(etype, name, next_value) + etype._member.append(emember) - next_value += 1 + next_value += 1 - return etype + return etype + except (PyODataParserError, AttributeError) as ex: + config.err_policy(ParserError.ENUM_TYPE).resolve(ex) + return NullType(type_node.get('Name')) From 92a12bcce68c7bdfef2a4e4a37eddad2c95d13ee Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Sat, 16 Nov 2019 19:59:54 +0100 Subject: [PATCH 12/36] Add V4 to pyodata cmd interface Also imports' paths were updated to correctly reflect project structure. --- CHANGELOG.md | 2 ++ bin/pyodata | 20 +++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 777b483c..a0dc3897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Support for navigation property in OData v4 - Martin Miksik - Support for EntitySet in OData v4 - Martin Miksik - Support for TypeDefinition in OData v4 - Martin Miksik +- Support for TypeDefinition in OData v4 - Martin Miksik +- Add V4 to pyodata cmd interface - Martin Miksik ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik diff --git a/bin/pyodata b/bin/pyodata index 5ef67db8..4351ec3d 100755 --- a/bin/pyodata +++ b/bin/pyodata @@ -4,7 +4,11 @@ import sys from argparse import ArgumentParser import pyodata -from pyodata.v2.model import PolicyFatal, PolicyWarning, PolicyIgnore, ParserError, Config +from pyodata.policies import PolicyFatal, PolicyWarning, PolicyIgnore, ParserError +from pyodata.config import Config + +from pyodata.v2 import ODataV2 +from pyodata.v4 import ODataV4 import requests @@ -42,7 +46,10 @@ def print_out_metadata_info(args, client): print(f' + {prop.name}({prop.typ.name})') for prop in es.entity_type.nav_proprties: - print(f' + {prop.name}({prop.to_role.entity_type_name})') + if client.schema.config.odata_version == ODataV2: + print(f' + {prop.name}({prop.to_role.entity_type_name})') + else: + print(f' + {prop.name}({prop.partner.name})') for fs in client.schema.function_imports: print(f'{fs.http_method} {fs.name}') @@ -90,6 +97,7 @@ def _parse_args(argv): help='Specify metadata parser default error handler') parser.add_argument('--custom-error-policy', action='append', type=str, help='Specify metadata parser custom error handlers in the form: TARGET=POLICY') + parser.add_argument('--version', default=2, choices=[2, 4], type=int) parser.set_defaults(func=print_out_metadata_info) @@ -145,10 +153,16 @@ def _main(argv): def get_config(): if config is None: - return Config() + version = ODataV4 + if args.version == 2: + version = ODataV2 + + return Config(odata_version=version) return config + config = get_config() + if args.default_error_policy: config = get_config() config.set_default_error_policy(ERROR_POLICIES[args.default_error_policy]()) From 37382de3b052649f9c3fe3501e2aa5f902521ec7 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Wed, 27 Nov 2019 16:52:35 +0100 Subject: [PATCH 13/36] Fix import error in python 3.6 In Python 3.7 importlib which resolves imports was updated. It allowed to use "import x.y as c" in __x__.py, but as we support python 3.6 as well we needed to optimize to work properly even with previous versions of importlib. Thus, the reason for this commit. All imports of files defined in the same folder as __module__.py are now in the form of "from .x import a,b,c". Also, since now all relevant classes for using pyodata e. g. elements, types are now directly imported in the appropriate module. User should always use API exposed directly from importing "pyodata.v2" or "pyodata.v4" Moreover, to remove cyclic imports: 1) Adapter function for build_entity_set(Credit to Jakub Filak) was added as well as class. 2) ODATAVersion was moved to separate file. 3) Redundant function schema_from_xml which required importing pyodata.v2 was removed. Used MetadataBuilder(xml, Config(ODataV2)) instead. --- CHANGELOG.md | 1 + pyodata/config.py | 43 ++++------------------------ pyodata/model/build_functions.py | 20 ++++--------- pyodata/model/builder.py | 19 +------------ pyodata/v2/__init__.py | 48 ++++++++++++++++---------------- pyodata/v4/__init__.py | 37 ++++++++++++------------ pyodata/v4/build_functions.py | 18 ++++++++++++ pyodata/version.py | 36 ++++++++++++++++++++++++ tests/conftest.py | 10 +++++-- tests/test_model.py | 3 +- tests/test_model_v2.py | 34 +++++++++++----------- 11 files changed, 137 insertions(+), 132 deletions(-) create mode 100644 pyodata/version.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a0dc3897..683e7918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - make sure configured error policies are applied for Annotations referencing unknown type/member - Martin Miksik - Race condition in `test_types_repository_separation` - Martin Miksik +- Import error while using python version prior to 3.7 - Martin Miksik ## [1.3.0] diff --git a/pyodata/config.py b/pyodata/config.py index 38a83dd8..3891bad7 100644 --- a/pyodata/config.py +++ b/pyodata/config.py @@ -1,41 +1,8 @@ -""" Contains definition of configuration class for PyOData and for ODATA versions. """ - -from abc import ABC, abstractmethod -from typing import Type, List, Dict, Callable, TYPE_CHECKING +""" Contains definition of configuration class for PyOData""" +from typing import Type, Dict from pyodata.policies import PolicyFatal, ParserError, ErrorPolicy - -# pylint: disable=cyclic-import -if TYPE_CHECKING: - from pyodata.model.elements import Typ, Annotation # noqa - - -class ODATAVersion(ABC): - """ This is base class for different OData releases. In it we define what are supported types, elements and so on. - Furthermore, we specify how individual elements are parsed or represented by python objects. - """ - - def __init__(self): - raise RuntimeError('ODATAVersion and its children are intentionally stateless, ' - 'therefore you can not create instance of them') - - # Separate dictionary of all registered types (primitive, complex and collection variants) for each child - Types: Dict[str, 'Typ'] = dict() - - @staticmethod - @abstractmethod - def primitive_types() -> List['Typ']: - """ Here we define which primitive types are supported and what is their python representation""" - - @staticmethod - @abstractmethod - def build_functions() -> Dict[type, Callable]: - """ Here we define which elements are supported and what is their python representation""" - - @staticmethod - @abstractmethod - def annotations() -> Dict['Annotation', Callable]: - """ Here we define which annotations are supported and what is their python representation""" +import pyodata.version class Config: @@ -46,7 +13,7 @@ class Config: """ This is configuration class for PyOData. All session dependent settings should be stored here. """ def __init__(self, - odata_version: Type[ODATAVersion], + odata_version: Type[pyodata.version.ODATAVersion], custom_error_policies=None, default_error_policy=None, xml_namespaces=None @@ -108,7 +75,7 @@ def namespaces(self, value: Dict[str, str]): self._namespaces = value @property - def odata_version(self) -> Type[ODATAVersion]: + def odata_version(self) -> Type[pyodata.version.ODATAVersion]: return self._odata_version @property diff --git a/pyodata/model/build_functions.py b/pyodata/model/build_functions.py index 7e1cee55..ec9f48c9 100644 --- a/pyodata/model/build_functions.py +++ b/pyodata/model/build_functions.py @@ -11,10 +11,6 @@ Types, EntitySet, ValueHelper, ValueHelperParameter, FunctionImportParameter, \ FunctionImport, metadata_attribute_get, EntityType, ComplexType, build_element, NullType -# pylint: disable=cyclic-import -# When using `import xxx as yyy` it is not a problem and we need this dependency -import pyodata.v4 as v4 - def modlog(): return logging.getLogger("callbacks") @@ -112,15 +108,11 @@ def build_entity_type(config: Config, type_node, schema=None): return NullType(type_node.get('Name')) -def build_entity_set(config, entity_set_node): +def build_entity_set(config, entity_set_node, builder=None): # pylint: disable=too-many-locals name = entity_set_node.get('Name') et_info = Types.parse_type_name(entity_set_node.get('EntityType')) - nav_prop_bins = [] - for nav_prop_bin in entity_set_node.xpath('edm:NavigationPropertyBinding', namespaces=config.namespaces): - nav_prop_bins.append(build_element('NavigationPropertyBinding', config, node=nav_prop_bin, et_info=et_info)) - # TODO: create a class SAP attributes addressable = sap_attribute_get_bool(entity_set_node, 'addressable', True) creatable = sap_attribute_get_bool(entity_set_node, 'creatable', True) @@ -133,12 +125,12 @@ def build_entity_set(config, entity_set_node): req_filter = sap_attribute_get_bool(entity_set_node, 'requires-filter', False) label = sap_attribute_get_string(entity_set_node, 'label') - if config.odata_version == v4.ODataV4: - return v4.EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, - pageable, topable, req_filter, label, nav_prop_bins) + if builder: + return builder(config, entity_set_node, name, et_info, addressable, creatable, updatable, deletable, searchable, + countable, pageable, topable, req_filter, label) - return EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, - topable, req_filter, label) + return EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, + pageable, topable, req_filter, label) def build_value_helper(config, target, annotation_node, schema): diff --git a/pyodata/model/builder.py b/pyodata/model/builder.py index cf0fed85..a7155293 100644 --- a/pyodata/model/builder.py +++ b/pyodata/model/builder.py @@ -6,7 +6,6 @@ from pyodata.config import Config from pyodata.exceptions import PyODataParserError from pyodata.model.elements import ValueHelperParameter, Schema, build_element -import pyodata.v2 as v2 ANNOTATION_NAMESPACES = { @@ -40,11 +39,8 @@ class MetadataBuilder: 'http://docs.oasis-open.org/odata/ns/edm' ] - def __init__(self, xml, config=None): + def __init__(self, xml, config): self._xml = xml - - if config is None: - config = Config(v2.ODataV2) self._config = config # pylint: disable=missing-docstring @@ -132,16 +128,3 @@ def update_alias(aliases, config: Config): if alias_namespace == namespace: config._sap_value_helper_directions[alias + '.' + suffix] = \ config.sap_value_helper_directions[direction_key] - - -def schema_from_xml(metadata_xml, namespaces=None): - """Parses XML data and returns Schema representing OData Metadata""" - - meta = MetadataBuilder( - metadata_xml, - config=Config( - v2.ODataV2, - xml_namespaces=namespaces, - )) - - return meta.build() diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index 712f17c6..c16e1d75 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -3,20 +3,20 @@ import logging from typing import List -from pyodata.v2.type_traits import EdmDateTimeTypTraits +from pyodata.version import ODATAVersion +from pyodata.model.elements import StructTypeProperty, StructType, ComplexType, EntityType, EntitySet, ValueHelper, \ + ValueHelperParameter, FunctionImport, Typ +from pyodata.model.build_functions import build_value_helper, build_entity_type, build_complex_type, \ + build_value_helper_parameter, build_entity_set, build_struct_type_property, build_struct_type, build_function_import from pyodata.model.type_traits import EdmBooleanTypTraits, EdmPrefixedTypTraits, EdmIntTypTraits, \ EdmLongIntTypTraits, EdmStringTypTraits -from pyodata.config import ODATAVersion -from pyodata.v2.elements import NavigationTypeProperty, EndRole, Association, AssociationSetEndRole, AssociationSet, \ +from .elements import NavigationTypeProperty, EndRole, Association, AssociationSetEndRole, AssociationSet, \ ReferentialConstraint, Schema -from pyodata.model.elements import StructTypeProperty, StructType, ComplexType, EntityType, EntitySet, ValueHelper, \ - ValueHelperParameter, FunctionImport, Typ - - -import pyodata.v2.build_functions as build_functions_v2 -import pyodata.model.build_functions as build_functions +from .build_functions import build_association_set, build_end_role, build_association, build_schema, \ + build_navigation_type_property, build_referential_constraint, build_association_set_end_role +from .type_traits import EdmDateTimeTypTraits def modlog(): @@ -30,20 +30,20 @@ class ODataV2(ODATAVersion): @staticmethod def build_functions(): return { - StructTypeProperty: build_functions.build_struct_type_property, - StructType: build_functions.build_struct_type, - NavigationTypeProperty: build_functions_v2.build_navigation_type_property, - ComplexType: build_functions.build_complex_type, - EntityType: build_functions.build_entity_type, - EntitySet: build_functions.build_entity_set, - EndRole: build_functions_v2.build_end_role, - ReferentialConstraint: build_functions_v2.build_referential_constraint, - Association: build_functions_v2.build_association, - AssociationSetEndRole: build_functions_v2.build_association_set_end_role, - AssociationSet: build_functions_v2.build_association_set, - ValueHelperParameter: build_functions.build_value_helper_parameter, - FunctionImport: build_functions.build_function_import, - Schema: build_functions_v2.build_schema + StructTypeProperty: build_struct_type_property, + StructType: build_struct_type, + NavigationTypeProperty: build_navigation_type_property, + ComplexType: build_complex_type, + EntityType: build_entity_type, + EntitySet: build_entity_set, + EndRole: build_end_role, + ReferentialConstraint: build_referential_constraint, + Association: build_association, + AssociationSetEndRole: build_association_set_end_role, + AssociationSet: build_association_set, + ValueHelperParameter: build_value_helper_parameter, + FunctionImport: build_function_import, + Schema: build_schema } @staticmethod @@ -70,5 +70,5 @@ def primitive_types() -> List[Typ]: @staticmethod def annotations(): return { - ValueHelper: build_functions.build_value_helper + ValueHelper: build_value_helper } diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py index 021830ef..1f464338 100644 --- a/pyodata/v4/__init__.py +++ b/pyodata/v4/__init__.py @@ -2,17 +2,18 @@ from typing import List -from pyodata.config import ODATAVersion -from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits +from pyodata.version import ODATAVersion from pyodata.model.elements import Typ, Schema, ComplexType, StructType, StructTypeProperty, EntityType +from pyodata.model.build_functions import build_entity_type, build_complex_type, build_struct_type_property, \ + build_struct_type +from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits -from pyodata.v4.elements import NavigationTypeProperty, NavigationPropertyBinding, EntitySet, Unit, EnumType -from pyodata.v4.type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ +from .elements import NavigationTypeProperty, NavigationPropertyBinding, EntitySet, Unit, EnumType +from .build_functions import build_unit_annotation, build_type_definition, build_schema, \ + build_navigation_type_property, build_navigation_property_binding, build_entity_set_with_v4_builder, build_enum_type +from .type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration -import pyodata.v4.build_functions as build_functions_v4 -import pyodata.model.build_functions as build_functions - class ODataV4(ODATAVersion): """ Definition of OData V4 """ @@ -20,16 +21,16 @@ class ODataV4(ODATAVersion): @staticmethod def build_functions(): return { - StructTypeProperty: build_functions.build_struct_type_property, - StructType: build_functions.build_struct_type, - NavigationTypeProperty: build_functions_v4.build_navigation_type_property, - NavigationPropertyBinding: build_functions_v4.build_navigation_property_binding, - EnumType: build_functions_v4.build_enum_type, - ComplexType: build_functions.build_complex_type, - EntityType: build_functions.build_entity_type, - EntitySet: build_functions.build_entity_set, - Typ: build_functions_v4.build_type_definition, - Schema: build_functions_v4.build_schema, + StructTypeProperty: build_struct_type_property, + StructType: build_struct_type, + NavigationTypeProperty: build_navigation_type_property, + NavigationPropertyBinding: build_navigation_property_binding, + EnumType: build_enum_type, + ComplexType: build_complex_type, + EntityType: build_entity_type, + EntitySet: build_entity_set_with_v4_builder, + Typ: build_type_definition, + Schema: build_schema, } @staticmethod @@ -75,5 +76,5 @@ def primitive_types() -> List[Typ]: @staticmethod def annotations(): return { - Unit: build_functions_v4.build_unit_annotation + Unit: build_unit_annotation } diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index 5f8c8a12..afdfe5e8 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -8,6 +8,7 @@ from pyodata.config import Config from pyodata.exceptions import PyODataParserError, PyODataModelError +from pyodata.model.build_functions import build_entity_set from pyodata.model.elements import ComplexType, Schema, NullType, build_element, EntityType, Types, \ StructTypeProperty, build_annotation, Typ from pyodata.policies import ParserError @@ -166,6 +167,23 @@ def build_type_definition(config: Config, node): return typ +# pylint: disable=too-many-arguments +def build_entity_set_v4(config, entity_set_node, name, et_info, addressable, creatable, updatable, deletable, + searchable, countable, pageable, topable, req_filter, label): + nav_prop_bins = [] + for nav_prop_bin in entity_set_node.xpath('edm:NavigationPropertyBinding', namespaces=config.namespaces): + nav_prop_bins.append(build_element(NavigationPropertyBinding, config, node=nav_prop_bin, et_info=et_info)) + + return EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable, + topable, req_filter, label, nav_prop_bins) + + +def build_entity_set_with_v4_builder(config, entity_set_node): + """Adapter inserting the V4 specific builder""" + + return build_entity_set(config, entity_set_node, builder=build_entity_set_v4) + + # pylint: disable=protected-access, too-many-locals def build_enum_type(config: Config, type_node, namespace): try: diff --git a/pyodata/version.py b/pyodata/version.py new file mode 100644 index 00000000..bdfdcd3b --- /dev/null +++ b/pyodata/version.py @@ -0,0 +1,36 @@ +""" Base class for defining ODATA versions. """ + +from abc import ABC, abstractmethod +from typing import List, Dict, Callable, TYPE_CHECKING + +# pylint: disable=cyclic-import +if TYPE_CHECKING: + from pyodata.model.elements import Typ, Annotation # noqa + + +class ODATAVersion(ABC): + """ This is base class for different OData releases. In it we define what are supported types, elements and so on. + Furthermore, we specify how individual elements are parsed or represented by python objects. + """ + + def __init__(self): + raise RuntimeError('ODATAVersion and its children are intentionally stateless, ' + 'therefore you can not create instance of them') + + # Separate dictionary of all registered types (primitive, complex and collection variants) for each child + Types: Dict[str, 'Typ'] = dict() + + @staticmethod + @abstractmethod + def primitive_types() -> List['Typ']: + """ Here we define which primitive types are supported and what is their python representation""" + + @staticmethod + @abstractmethod + def build_functions() -> Dict[type, Callable]: + """ Here we define which elements are supported and what is their python representation""" + + @staticmethod + @abstractmethod + def annotations() -> Dict['Annotation', Callable]: + """ Here we define which annotations are supported and what is their python representation""" diff --git a/tests/conftest.py b/tests/conftest.py index 34f31fe1..08b10368 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,8 @@ import pytest from pyodata.config import Config -from pyodata.model.builder import schema_from_xml, MetadataBuilder +from pyodata.model.builder import MetadataBuilder +from pyodata.v2 import ODataV2 from pyodata.v4 import ODataV4 @@ -124,7 +125,12 @@ def schema(metadata_v2): # pylint: disable=redefined-outer-name - return schema_from_xml(metadata_v2) + meta = MetadataBuilder( + metadata_v2, + config=Config(ODataV2) + ) + + return meta.build() @pytest.fixture diff --git a/tests/test_model.py b/tests/test_model.py index da4b6f26..4a79e296 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,7 +1,8 @@ from typing import List import pytest -from pyodata.config import Config, ODATAVersion +from pyodata.config import Config +from pyodata.version import ODATAVersion from pyodata.exceptions import PyODataParserError from pyodata.model.builder import MetadataBuilder from pyodata.model.elements import Schema, Types, Typ diff --git a/tests/test_model_v2.py b/tests/test_model_v2.py index 9198da3c..3630c851 100644 --- a/tests/test_model_v2.py +++ b/tests/test_model_v2.py @@ -625,7 +625,7 @@ def test_annot_v_l_missing_e_s(mock_warning, xml_builder_factory): """ ) - metadata = MetadataBuilder(xml_builder.serialize()) + metadata = MetadataBuilder(xml_builder.serialize(), Config(ODataV2)) with pytest.raises(RuntimeError) as e_info: metadata.build() @@ -677,7 +677,7 @@ def test_annot_v_l_missing_e_t(mock_warning, xml_builder_factory): """ ) - metadata = MetadataBuilder(xml_builder.serialize()) + metadata = MetadataBuilder(xml_builder.serialize(), Config(ODataV2)) try: metadata.build() @@ -737,7 +737,7 @@ def test_annot_v_l_trgt_inv_prop(mock_warning, mock_resolve, xml_builder_factory """ ) - metadata = MetadataBuilder(xml_builder.serialize()) + metadata = MetadataBuilder(xml_builder.serialize(), Config(ODataV2)) with pytest.raises(RuntimeError) as typ_ex_info: metadata.build() @@ -808,7 +808,7 @@ def test_namespace_with_periods(xml_builder_factory): """ ) - schema = MetadataBuilder(xml_builder.serialize()).build() + schema = MetadataBuilder(xml_builder.serialize(), Config(ODataV2)).build() db_entity = schema.entity_type('Database') @@ -982,7 +982,7 @@ def test_missing_association_for_navigation_property(xml_builder_factory): """) - metadata = MetadataBuilder(xml_builder.serialize()) + metadata = MetadataBuilder(xml_builder.serialize(), Config(ODataV2)) with pytest.raises(KeyError) as typ_ex_info: metadata.build() @@ -1039,7 +1039,7 @@ def test_missing_data_service(xml_builder_factory): xml = xml_builder.serialize() try: - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).build() except PyODataParserError as ex: assert str(ex) == 'Metadata document is missing the element DataServices' @@ -1052,7 +1052,7 @@ def test_missing_schema(xml_builder_factory): xml = xml_builder.serialize() try: - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).build() except PyODataParserError as ex: assert str(ex) == 'Metadata document is missing the element Schema' @@ -1067,7 +1067,7 @@ def test_namespace_whitelist(mock_build_element: MagicMock, xml_builder_factory) xml_builder.add_schema('', '') xml = xml_builder.serialize() - assert MetadataBuilder(xml).build() == 'Mocked' + assert MetadataBuilder(xml, Config(ODataV2)).build() == 'Mocked' @patch('pyodata.model.builder.build_element', return_value='Mocked') @@ -1091,7 +1091,7 @@ def test_unsupported_edmx_n(mock_build_element, xml_builder_factory): assert schema == 'Mocked' try: - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).build() except PyODataParserError as ex: assert str(ex) == f'Unsupported Edmx namespace - {edmx}' @@ -1119,7 +1119,7 @@ def test_unsupported_schema_n(mock_build_element, xml_builder_factory): assert schema == 'Mocked' try: - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).build() except PyODataParserError as ex: assert str(ex) == f'Unsupported Schema namespace - {edm}' @@ -1135,10 +1135,10 @@ def test_whitelisted_edm_namespace(mock_from_etree, xml_builder_factory): xml_builder.add_schema('', '') xml = xml_builder.serialize() - assert MetadataBuilder(xml).build() == 'Mocked' + assert MetadataBuilder(xml, Config(ODataV2)).build() == 'Mocked' -@patch('pyodata.v2.build_functions_v2.build_schema') +@patch('pyodata.v2.build_schema') def test_whitelisted_edm_namespace_2006_04(mocked, xml_builder_factory): """Test correct handling of whitelisted Microsoft's edm namespace""" @@ -1147,11 +1147,11 @@ def test_whitelisted_edm_namespace_2006_04(mocked, xml_builder_factory): xml_builder.add_schema('', '') xml = xml_builder.serialize() - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).build() mocked.assert_called_once() -@patch('pyodata.v2.build_functions_v2.build_schema') +@patch('pyodata.v2.build_schema') def test_whitelisted_edm_namespace_2007_05(mocked, xml_builder_factory): """Test correct handling of whitelisted Microsoft's edm namespace""" @@ -1160,7 +1160,7 @@ def test_whitelisted_edm_namespace_2007_05(mocked, xml_builder_factory): xml_builder.add_schema('', '') xml = xml_builder.serialize() - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).build() mocked.assert_called_once() @@ -1208,7 +1208,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac xml = xml_builder.serialize() with pytest.raises(RuntimeError) as typ_ex_info: - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).build() assert typ_ex_info.value.args[0] == 'ValueHelperParameter(Type) of ValueHelper(MasterEntity/Data) points to ' \ 'an non existing LocalDataProperty --- of EntityType(MasterEntity)' @@ -1230,7 +1230,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac xml = xml_builder.serialize() with pytest.raises(RuntimeError) as typ_ex_info: - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).build() assert typ_ex_info.value.args[0] == 'ValueHelperParameter(---) of ValueHelper(MasterEntity/Data) points to an non ' \ 'existing ValueListProperty --- of EntityType(DataEntity)' From 41d26fb0bebe24c4538a24bdac04bce6528c821f Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 29 Nov 2019 11:11:14 +0100 Subject: [PATCH 14/36] Fix parsing datetime containing timezone information for python 3.6 Required method for parsing datetime 'fromisoformat' was added in python version 3.7, thus backport of that method was added to the requirements list. Also '%z' directive was updated in python 3.7 to support timezone information in the "+xx:xx" or "Z" format. Hence, changes were made to allow compatibility of these notations in python 3.6 and lower. https://docs.python.org/3/library/datetime.html --- CHANGELOG.md | 1 + pyodata/v4/type_traits.py | 12 ++++++++++++ requirements.txt | 1 + 3 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 683e7918..3e633971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). unknown type/member - Martin Miksik - Race condition in `test_types_repository_separation` - Martin Miksik - Import error while using python version prior to 3.7 - Martin Miksik +- Parsing datetime containing timezone information for python 3.6 and lower - Martin Miksik ## [1.3.0] diff --git a/pyodata/v4/type_traits.py b/pyodata/v4/type_traits.py index 49a11d2c..97d7d3ec 100644 --- a/pyodata/v4/type_traits.py +++ b/pyodata/v4/type_traits.py @@ -1,5 +1,6 @@ """ Type traits for types specific to the ODATA V4""" +import sys import datetime # In case you want to use geojson types. You have to install pip package 'geojson' @@ -14,6 +15,10 @@ from pyodata.exceptions import PyODataModelError, PyODataException from pyodata.model.type_traits import TypTraits +if sys.version_info < (3, 7): + from backports.datetime_fromisoformat import MonkeyPatch + MonkeyPatch.patch_fromisoformat() + class EdmDoubleQuotesEncapsulatedTypTraits(TypTraits): """Good for all types which are encapsulated in double quotes""" @@ -218,6 +223,13 @@ def from_literal(self, value: str): value = super().from_literal(value) + if sys.version_info < (3, 7): + if value[len(value) - 3] == ':': + value = value[:len(value) - 3] + value[-2:] + + if value[len(value) - 1] == 'Z': + value = value[:len(value) - 1] + "+0000" + try: value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f%z') except ValueError: diff --git a/requirements.txt b/requirements.txt index 69af6a66..78f44d6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ lxml>=3.7.3 +backports-datetime-fromisoformat>=1.0 From de69e21e229eb61ac97e7064c971385319f872ea Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Sat, 30 Nov 2019 09:48:22 +0100 Subject: [PATCH 15/36] Change default value of precision if non is provided in metadata According to ODATA V4 specification when precision is not provided it defaults to infinity. ODATA V2 specification does not provide any information regarding the default value and ODATA V3 specification is unclear. Thus, following the ODATA V4 specification for all versions seams as the best idea. https://www.odata.org/documentation/odata-version-3-0/common-schema-definition-language-csdl/#csdl5.3.4 https://docs.oasis-open.org/odata/odata-csdl-xml/v4.01/csprd06/odata-csdl-xml-v4.01-csprd06.html#sec_Precision https://docs.oasis-open.org/odata/odata-csdl-xml/v4.01/csprd06/odata-csdl-xml-v4.01-csprd06.html#sec_Scale --- CHANGELOG.md | 3 ++- pyodata/model/elements.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e633971..59285bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik -- Build functions of struct types now handle invalid metadata independently. - Martin Miksik +- Build functions of struct types now handle invalid metadata independently. - Martin Miksik +- Default value of precision if non is provided in metadata - Martin Miksik ### Fixed - make sure configured error policies are applied for Annotations referencing diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 504e04bd..535b29ff 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -293,7 +293,7 @@ def __init__(self, name, type_info, nullable, max_length, precision, scale): self._max_length = int(max_length) if not precision: - self._precision = 0 + self._precision = None else: self._precision = int(precision) if not scale: @@ -337,7 +337,7 @@ def scale(self): return self._scale def _check_scale_value(self): - if self._scale > self._precision: + if self._precision and self._scale > self._precision: raise PyODataModelError('Scale value ({}) must be less than or equal to precision value ({})' .format(self._scale, self._precision)) From 25a782d98d7867cafb88649438a7d47a9760ca3b Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Wed, 4 Dec 2019 13:08:18 +0100 Subject: [PATCH 16/36] Add untracked files to the .gitignore Folder .htmlcov is generated by the "make report-coverage-html" --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 85b79ba3..04d8f9f6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ venv dist .idea .coverage +.htmlcov/ From 474cd3a84a44e197d6819a97eaf92ba18497427f Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 13 Dec 2019 17:03:48 +0100 Subject: [PATCH 17/36] Fix type hinting for ErrorPolicy's children Type hint Type[ErrorPolicy] was not resoling correctly for its children. --- CHANGELOG.md | 1 + pyodata/config.py | 12 ++++++------ pyodata/policies.py | 4 ++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59285bf0..78404f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Race condition in `test_types_repository_separation` - Martin Miksik - Import error while using python version prior to 3.7 - Martin Miksik - Parsing datetime containing timezone information for python 3.6 and lower - Martin Miksik +- Type hinting for ErrorPolicy's children - Martin Miksik ## [1.3.0] diff --git a/pyodata/config.py b/pyodata/config.py index 3891bad7..395626d8 100644 --- a/pyodata/config.py +++ b/pyodata/config.py @@ -1,7 +1,7 @@ """ Contains definition of configuration class for PyOData""" from typing import Type, Dict -from pyodata.policies import PolicyFatal, ParserError, ErrorPolicy +from pyodata.policies import PolicyFatal, ParserError, ErrorPolicyType import pyodata.version @@ -14,8 +14,8 @@ class Config: def __init__(self, odata_version: Type[pyodata.version.ODATAVersion], - custom_error_policies=None, - default_error_policy=None, + custom_error_policies: Dict[ParserError, ErrorPolicyType] = None, + default_error_policy: ErrorPolicyType = None, xml_namespaces=None ): @@ -48,19 +48,19 @@ def __init__(self, self._annotation_namespaces = None self._aliases: Dict[str, str] = dict() - def err_policy(self, error: ParserError) -> ErrorPolicy: + def err_policy(self, error: ParserError) -> ErrorPolicyType: """ Returns error policy for given error. If custom error policy fo error is set, then returns that.""" if self._custom_error_policy is None: return self._default_error_policy return self._custom_error_policy.get(error, self._default_error_policy) - def set_default_error_policy(self, policy: ErrorPolicy): + def set_default_error_policy(self, policy: ErrorPolicyType): """ Sets default error policy as well as resets custom error policies""" self._custom_error_policy = None self._default_error_policy = policy - def set_custom_error_policy(self, policies: Dict[ParserError, Type[ErrorPolicy]]): + def set_custom_error_policy(self, policies: Dict[ParserError, ErrorPolicyType]): """ Sets custom error policy. It should be called only after setting default error policy, otherwise it has no effect. See implementation of "set_default_error_policy" for more details. """ diff --git a/pyodata/policies.py b/pyodata/policies.py index 5f74ad8a..fa85c4fc 100644 --- a/pyodata/policies.py +++ b/pyodata/policies.py @@ -6,6 +6,7 @@ import logging from abc import ABC, abstractmethod from enum import Enum, auto +from typing import TypeVar class ParserError(Enum): @@ -23,6 +24,9 @@ class ParserError(Enum): REFERENTIAL_CONSTRAINT = auto() +ErrorPolicyType = TypeVar("ErrorPolicyType", bound="ErrorPolicy") + + class ErrorPolicy(ABC): """ All policies has to inhere this class""" @abstractmethod From 2d6ccaaa41bbb7f5b5b0fa075f7fa26d3ad86db1 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 13 Dec 2019 17:11:32 +0100 Subject: [PATCH 18/36] Add permissive parsing for TypeDefinition Before it was not possible to skip parsing of invalid TypeDefinition node. --- CHANGELOG.md | 1 + pyodata/policies.py | 1 + pyodata/v4/build_functions.py | 18 +++++++++++------- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78404f7a..a4afa0c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Support for TypeDefinition in OData v4 - Martin Miksik - Support for TypeDefinition in OData v4 - Martin Miksik - Add V4 to pyodata cmd interface - Martin Miksik +- Permissive parsing for TypeDefinition ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik diff --git a/pyodata/policies.py b/pyodata/policies.py index fa85c4fc..58f62425 100644 --- a/pyodata/policies.py +++ b/pyodata/policies.py @@ -17,6 +17,7 @@ class ParserError(Enum): ANNOTATION = auto() ASSOCIATION = auto() + TYPE_DEFINITION = auto() ENUM_TYPE = auto() ENTITY_TYPE = auto() ENTITY_SET = auto() diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index afdfe5e8..367e7b19 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -156,13 +156,17 @@ def build_unit_annotation(config: Config, target: Typ, annotation_node): def build_type_definition(config: Config, node): - typ = copy.deepcopy(Types.from_name(node.get('UnderlyingType'), config)) - typ.name = node.get('Name') - - annotation_nodes = node.xpath('edm:Annotation', namespaces=config.namespaces) - if annotation_nodes: - annotation_node = annotation_nodes[0] - build_annotation(annotation_node.get('Term'), config, target=typ, annotation_node=annotation_node) + try: + typ = copy.deepcopy(Types.from_name(node.get('UnderlyingType'), config)) + typ.name = node.get('Name') + + annotation_nodes = node.xpath('edm:Annotation', namespaces=config.namespaces) + if annotation_nodes: + annotation_node = annotation_nodes[0] + build_annotation(annotation_node.get('Term'), config, target=typ, annotation_node=annotation_node) + except KeyError as ex: + config.err_policy(ParserError.TYPE_DEFINITION).resolve(ex) + typ = NullType(node.get('Name')) return typ From 961c950e6b4767d7006b2de7fce24ceea80fde2f Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Thu, 19 Dec 2019 12:07:17 +0100 Subject: [PATCH 19/36] Changes all manually raised exception to be child of PyODataException This is to better handle permissive parsing as otherwise we could be catching unwanted exceptions. Test were updated to expect different exceptions. --- CHANGELOG.md | 1 + pyodata/model/build_functions.py | 31 ++++------ pyodata/model/elements.py | 101 +++++++++++++++++-------------- pyodata/v2/build_functions.py | 28 ++++----- pyodata/v2/elements.py | 17 +++--- pyodata/v2/service.py | 6 +- pyodata/v4/build_functions.py | 6 +- tests/test_model.py | 7 ++- tests/test_model_v2.py | 53 ++++++++-------- tests/test_model_v4.py | 12 ++-- tests/test_service_v2.py | 6 +- 11 files changed, 135 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4afa0c0..ef31db7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Support for TypeDefinition in OData v4 - Martin Miksik - Add V4 to pyodata cmd interface - Martin Miksik - Permissive parsing for TypeDefinition +- Changes all manually raised exception to be child of PyODataException - Martin Miksik ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik diff --git a/pyodata/model/build_functions.py b/pyodata/model/build_functions.py index ec9f48c9..186a2ef3 100644 --- a/pyodata/model/build_functions.py +++ b/pyodata/model/build_functions.py @@ -64,7 +64,7 @@ def build_struct_type(config: Config, type_node, typ, schema=None): stp = build_element(StructTypeProperty, config, entity_type_property_node=proprty) if stp.name in stype._properties: - raise KeyError('{0} already has property {1}'.format(stype, stp.name)) + raise PyODataParserError('{0} already has property {1}'.format(stype, stp.name)) stype._properties[stp.name] = stp @@ -81,7 +81,7 @@ def build_struct_type(config: Config, type_node, typ, schema=None): def build_complex_type(config: Config, type_node, schema=None): try: return build_element(StructType, config, type_node=type_node, typ=ComplexType, schema=schema) - except (PyODataException, KeyError, AttributeError) as ex: + except PyODataException as ex: config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex) return NullType(type_node.get('Name')) @@ -98,12 +98,12 @@ def build_entity_type(config: Config, type_node, schema=None): navp = build_element('NavigationTypeProperty', config, node=proprty) if navp.name in etype._nav_properties: - raise KeyError('{0} already has navigation property {1}'.format(etype, navp.name)) + raise PyODataParserError('{0} already has navigation property {1}'.format(etype, navp.name)) etype._nav_properties[navp.name] = navp return etype - except (KeyError, AttributeError) as ex: + except (PyODataParserError, AttributeError) as ex: config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) return NullType(type_node.get('Name')) @@ -160,29 +160,24 @@ def build_value_helper(config, target, annotation_node, schema): value_helper._parameters.append(param) try: - try: - value_helper.entity_set = schema.entity_set( - value_helper.collection_path, namespace=value_helper.element_namespace) - except KeyError: - raise RuntimeError(f'Entity Set {value_helper.collection_path} ' - f'for {value_helper} does not exist') - + value_helper.entity_set = schema.entity_set( + value_helper.collection_path, namespace=value_helper.element_namespace) try: vh_type = schema.typ(value_helper.proprty_entity_type_name, namespace=value_helper.element_namespace) - except KeyError: - raise RuntimeError(f'Target Type {value_helper.proprty_entity_type_name} ' - f'of {value_helper} does not exist') + except PyODataModelError: + raise PyODataParserError(f'Target Type {value_helper.proprty_entity_type_name} ' + f'of {value_helper} does not exist') try: target_proprty = vh_type.proprty(value_helper.proprty_name) - except KeyError: - raise RuntimeError(f'Target Property {value_helper.proprty_name} ' - f'of {vh_type} as defined in {value_helper} does not exist') + except PyODataModelError: + raise PyODataParserError(f'Target Property {value_helper.proprty_name} ' + f'of {vh_type} as defined in {value_helper} does not exist') value_helper.proprty = target_proprty target_proprty.value_helper = value_helper - except (RuntimeError, PyODataModelError) as ex: + except (PyODataModelError, PyODataParserError) as ex: config.err_policy(ParserError.ANNOTATION).resolve(ex) diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 535b29ff..4459de46 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -175,7 +175,7 @@ def from_name(name, config: Config) -> 'Typ': try: return o_version.Types[search_name] except KeyError: - raise KeyError('Requested primitive type is not supported in this version of ODATA') + raise PyODataModelError(f'Requested primitive type {search_name} is not supported in this version of ODATA') @staticmethod def parse_type_name(type_name): @@ -313,10 +313,10 @@ def typ(self): @typ.setter def typ(self, value): if self._typ is not None: - raise RuntimeError('Cannot replace {0} of {1} by {2}'.format(self._typ, self, value)) + raise PyODataModelError('Cannot replace {0} of {1} by {2}'.format(self._typ, self, value)) if value.name != self._type_info[1]: - raise RuntimeError('{0} cannot be the type of {1}'.format(value, self)) + raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) self._typ = value @@ -420,7 +420,7 @@ def __getitem__(self, key): try: return super(Schema.Declarations, self).__getitem__(key) except KeyError: - raise KeyError('There is no Schema Namespace {}'.format(key)) + raise PyODataModelError('There is no Schema Namespace {}'.format(key)) def __init__(self, config: Config): super(Schema, self).__init__() @@ -446,18 +446,19 @@ def typ(self, type_name, namespace=None): for type_space in (self.entity_type, self.complex_type, self.enum_type): try: return type_space(type_name, namespace=namespace) - except KeyError: + except PyODataModelError: pass - raise KeyError('Type {} does not exist in Schema{}' - .format(type_name, ' Namespace ' + namespace if namespace else '')) + raise PyODataModelError('Type {} does not exist in Schema{}' + .format(type_name, ' Namespace ' + namespace if namespace else '')) def entity_type(self, type_name, namespace=None): if namespace is not None: try: return self._decls[namespace].entity_types[type_name] except KeyError: - raise KeyError('EntityType {} does not exist in Schema Namespace {}'.format(type_name, namespace)) + raise PyODataModelError('EntityType {} does not exist in Schema Namespace {}' + .format(type_name, namespace)) for decl in list(self._decls.values()): try: @@ -465,14 +466,15 @@ def entity_type(self, type_name, namespace=None): except KeyError: pass - raise KeyError('EntityType {} does not exist in any Schema Namespace'.format(type_name)) + raise PyODataModelError('EntityType {} does not exist in any Schema Namespace'.format(type_name)) def complex_type(self, type_name, namespace=None): if namespace is not None: try: return self._decls[namespace].complex_types[type_name] except KeyError: - raise KeyError('ComplexType {} does not exist in Schema Namespace {}'.format(type_name, namespace)) + raise PyODataModelError('ComplexType {} does not exist in Schema Namespace {}' + .format(type_name, namespace)) for decl in list(self._decls.values()): try: @@ -480,14 +482,14 @@ def complex_type(self, type_name, namespace=None): except KeyError: pass - raise KeyError('ComplexType {} does not exist in any Schema Namespace'.format(type_name)) + raise PyODataModelError('ComplexType {} does not exist in any Schema Namespace'.format(type_name)) def enum_type(self, type_name, namespace=None): if namespace is not None: try: return self._decls[namespace].enum_types[type_name] except KeyError: - raise KeyError(f'EnumType {type_name} does not exist in Schema Namespace {namespace}') + raise PyODataModelError(f'EnumType {type_name} does not exist in Schema Namespace {namespace}') for decl in list(self._decls.values()): try: @@ -495,14 +497,14 @@ def enum_type(self, type_name, namespace=None): except KeyError: pass - raise KeyError(f'EnumType {type_name} does not exist in any Schema Namespace') + raise PyODataModelError(f'EnumType {type_name} does not exist in any Schema Namespace') def type_definition(self, name, namespace=None): if namespace is not None: try: return self._decls[namespace].type_definitions[name] except KeyError: - raise KeyError(f'EnumType {name} does not exist in Schema Namespace {namespace}') + raise PyODataModelError(f'EnumType {name} does not exist in Schema Namespace {namespace}') for decl in list(self._decls.values()): try: @@ -510,7 +512,7 @@ def type_definition(self, name, namespace=None): except KeyError: pass - raise KeyError(f'EnumType {name} does not exist in any Schema Namespace') + raise PyODataModelError(f'EnumType {name} does not exist in any Schema Namespace') def get_type(self, type_info): @@ -520,31 +522,31 @@ def get_type(self, type_info): # first look for type in primitive types try: return Types.from_name(search_name, self.config) - except KeyError: + except PyODataModelError: pass # then look for type in type definitions try: return self.type_definition(search_name, type_info.namespace) - except KeyError: + except PyODataModelError: pass # then look for type in entity types try: return self.entity_type(search_name, type_info.namespace) - except KeyError: + except PyODataModelError: pass # then look for type in complex types try: return self.complex_type(search_name, type_info.namespace) - except KeyError: + except PyODataModelError: pass # then look for type in enum types try: return self.enum_type(search_name, type_info.namespace) - except KeyError: + except PyODataModelError: pass raise PyODataModelError( @@ -568,7 +570,8 @@ def entity_set(self, set_name, namespace=None): try: return self._decls[namespace].entity_sets[set_name] except KeyError: - raise KeyError('EntitySet {} does not exist in Schema Namespace {}'.format(set_name, namespace)) + raise PyODataModelError('EntitySet {} does not exist in Schema Namespace {}' + .format(set_name, namespace)) for decl in list(self._decls.values()): try: @@ -576,7 +579,7 @@ def entity_set(self, set_name, namespace=None): except KeyError: pass - raise KeyError('EntitySet {} does not exist in any Schema Namespace'.format(set_name)) + raise PyODataModelError('EntitySet {} does not exist in any Schema Namespace'.format(set_name)) @property def entity_sets(self): @@ -587,8 +590,8 @@ def function_import(self, function_import, namespace=None): try: return self._decls[namespace].function_imports[function_import] except KeyError: - raise KeyError('FunctionImport {} does not exist in Schema Namespace {}' - .format(function_import, namespace)) + raise PyODataModelError('FunctionImport {} does not exist in Schema Namespace {}' + .format(function_import, namespace)) for decl in list(self._decls.values()): try: @@ -596,7 +599,7 @@ def function_import(self, function_import, namespace=None): except KeyError: pass - raise KeyError('FunctionImport {} does not exist in any Schema Namespace'.format(function_import)) + raise PyODataModelError('FunctionImport {} does not exist in any Schema Namespace'.format(function_import)) @property def function_imports(self): @@ -633,7 +636,10 @@ def is_value_list(self): return self._is_value_list def proprty(self, property_name): - return self._properties[property_name] + try: + return self._properties[property_name] + except KeyError: + raise PyODataModelError(f'Property {property_name} not found on {self}') def proprties(self): return list(self._properties.values()) @@ -713,10 +719,10 @@ def entity_type(self): @entity_type.setter def entity_type(self, value): if self._entity_type is not None: - raise RuntimeError('Cannot replace {0} of {1} to {2}'.format(self._entity_type, self, value)) + raise PyODataModelError('Cannot replace {0} of {1} to {2}'.format(self._entity_type, self, value)) if value.name != self.entity_type_info[1]: - raise RuntimeError('{0} cannot be the type of {1}'.format(value, self)) + raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) self._entity_type = value @@ -802,7 +808,7 @@ def struct_type(self): def struct_type(self, value): if self._struct_type is not None: - raise RuntimeError('Cannot replace {0} of {1} to {2}'.format(self._struct_type, self, value)) + raise PyODataModelError('Cannot replace {0} of {1} to {2}'.format(self._struct_type, self, value)) self._struct_type = value @@ -812,8 +818,8 @@ def struct_type(self, value): except KeyError: # TODO: resolve EntityType of text property if '/' not in self._text_proprty_name: - raise RuntimeError('The attribute sap:text of {1} is set to non existing Property \'{0}\'' - .format(self._text_proprty_name, self)) + raise PyODataModelError('The attribute sap:text of {1} is set to non existing Property \'{0}\'' + .format(self._text_proprty_name, self)) @property def text_proprty_name(self): @@ -883,7 +889,8 @@ def value_list(self): def value_helper(self, value): # Value Help property must not be changed if self._value_helper is not None: - raise RuntimeError('Cannot replace value helper {0} of {1} by {2}'.format(self._value_helper, self, value)) + raise PyODataModelError('Cannot replace value helper {0} of {1} by {2}' + .format(self._value_helper, self, value)) self._value_helper = value @@ -955,10 +962,10 @@ def proprty(self): @proprty.setter def proprty(self, value): if self._proprty is not None: - raise RuntimeError('Cannot replace {0} of {1} with {2}'.format(self._proprty, self, value)) + raise PyODataModelError('Cannot replace {0} of {1} with {2}'.format(self._proprty, self, value)) if value.struct_type.name != self.proprty_entity_type_name or value.name != self.proprty_name: - raise RuntimeError('{0} cannot be an annotation of {1}'.format(self, value)) + raise PyODataModelError('{0} cannot be an annotation of {1}'.format(self, value)) self._proprty = value @@ -967,8 +974,8 @@ def proprty(self, value): etype = self._proprty.struct_type try: param.local_property = etype.proprty(param.local_property_name) - except KeyError: - raise RuntimeError('{0} of {1} points to an non existing LocalDataProperty {2} of {3}'.format( + except PyODataModelError: + raise PyODataModelError('{0} of {1} points to an non existing LocalDataProperty {2} of {3}'.format( param, self, param.local_property_name, etype)) @property @@ -982,10 +989,10 @@ def entity_set(self): @entity_set.setter def entity_set(self, value): if self._entity_set is not None: - raise RuntimeError('Cannot replace {0} of {1} with {2}'.format(self._entity_set, self, value)) + raise PyODataModelError('Cannot replace {0} of {1} with {2}'.format(self._entity_set, self, value)) if value.name != self.collection_path: - raise RuntimeError('{0} cannot be assigned to {1}'.format(self, value)) + raise PyODataModelError('{0} cannot be assigned to {1}'.format(self, value)) self._entity_set = value @@ -994,8 +1001,8 @@ def entity_set(self, value): etype = self._entity_set.entity_type try: param.list_property = etype.proprty(param.list_property_name) - except KeyError: - raise RuntimeError('{0} of {1} points to an non existing ValueListProperty {2} of {3}'.format( + except PyODataModelError: + raise PyODataModelError('{0} of {1} points to an non existing ValueListProperty {2} of {3}'.format( param, self, param.list_property_name, etype)) @property @@ -1011,14 +1018,14 @@ def local_property_param(self, name): if prm.local_property.name == name: return prm - raise KeyError('{0} has no local property {1}'.format(self, name)) + raise PyODataModelError('{0} has no local property {1}'.format(self, name)) def list_property_param(self, name): for prm in self._parameters: if prm.list_property.name == name: return prm - raise KeyError('{0} has no list property {1}'.format(self, name)) + raise PyODataModelError('{0} has no list property {1}'.format(self, name)) class ValueHelperParameter(): @@ -1049,7 +1056,7 @@ def value_helper(self): @value_helper.setter def value_helper(self, value): if self._value_helper is not None: - raise RuntimeError('Cannot replace {0} of {1} with {2}'.format(self._value_helper, self, value)) + raise PyODataModelError('Cannot replace {0} of {1} with {2}'.format(self._value_helper, self, value)) self._value_helper = value @@ -1068,7 +1075,7 @@ def local_property(self): @local_property.setter def local_property(self, value): if self._local_property is not None: - raise RuntimeError('Cannot replace {0} of {1} with {2}'.format(self._local_property, self, value)) + raise PyODataModelError('Cannot replace {0} of {1} with {2}'.format(self._local_property, self, value)) self._local_property = value @@ -1083,7 +1090,7 @@ def list_property(self): @list_property.setter def list_property(self, value): if self._list_property is not None: - raise RuntimeError('Cannot replace {0} of {1} with {2}'.format(self._list_property, self, value)) + raise PyODataModelError('Cannot replace {0} of {1} with {2}'.format(self._list_property, self, value)) self._list_property = value @@ -1109,10 +1116,10 @@ def return_type(self): @return_type.setter def return_type(self, value): if self._return_type is not None: - raise RuntimeError('Cannot replace {0} of {1} by {2}'.format(self._return_type, self, value)) + raise PyODataModelError('Cannot replace {0} of {1} by {2}'.format(self._return_type, self, value)) if value.name != self.return_type_info[1]: - raise RuntimeError('{0} cannot be the type of {1}'.format(value, self)) + raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) self._return_type = value diff --git a/pyodata/v2/build_functions.py b/pyodata/v2/build_functions.py index d8cbab3a..289e1534 100644 --- a/pyodata/v2/build_functions.py +++ b/pyodata/v2/build_functions.py @@ -91,7 +91,7 @@ def build_schema(config: Config, schema_nodes): # Check if the role was defined in the current association if principal_role.name not in role_names: - raise RuntimeError( + raise PyODataParserError( 'Role {} was not defined in association {}'.format(principal_role.name, assoc.name)) # Check if principal role properties exist @@ -103,14 +103,14 @@ def build_schema(config: Config, schema_nodes): # Check if the role was defined in the current association if dependent_role.name not in role_names: - raise RuntimeError( + raise PyODataParserError( 'Role {} was not defined in association {}'.format(dependent_role.name, assoc.name)) # Check if dependent role properties exist role_name = dependent_role.name entity_type_name = assoc.end_by_role(role_name).entity_type_name schema.check_role_property_names(dependent_role, entity_type_name, namespace) - except (PyODataModelError, RuntimeError) as ex: + except (PyODataModelError, PyODataParserError) as ex: config.err_policy(ParserError.ASSOCIATION).resolve(ex) decl.associations[assoc.name] = NullAssociation(assoc.name) else: @@ -179,7 +179,7 @@ def build_schema(config: Config, schema_nodes): if assoc_set.association_type.end_by_role(end.role) is None: raise PyODataModelError('Role {} is not defined in association {}' .format(end.role, assoc_set.association_type_name)) - except (PyODataModelError, KeyError) as ex: + except PyODataModelError as ex: config.err_policy(ParserError.ASSOCIATION).resolve(ex) decl.association_sets[assoc_set.name] = NullAssociation(assoc_set.name) else: @@ -224,15 +224,15 @@ def build_association(config: Config, association_node): for end in association_node.xpath('edm:End', namespaces=config.namespaces): end_role = build_element(EndRole, config, end_role_node=end) if end_role.entity_type_info is None: - raise RuntimeError('End type is not specified in the association {}'.format(name)) + raise PyODataParserError('End type is not specified in the association {}'.format(name)) association._end_roles.append(end_role) if len(association._end_roles) != 2: - raise RuntimeError('Association {} does not have two end roles'.format(name)) + raise PyODataParserError('Association {} does not have two end roles'.format(name)) refer = association_node.xpath('edm:ReferentialConstraint', namespaces=config.namespaces) if len(refer) > 1: - raise RuntimeError('In association {} is defined more than one referential constraint'.format(name)) + raise PyODataParserError('In association {} is defined more than one referential constraint'.format(name)) if not refer: referential_constraint = None @@ -269,32 +269,32 @@ def build_association_set(config: Config, association_set_node): def build_referential_constraint(config: Config, referential_constraint_node): principal = referential_constraint_node.xpath('edm:Principal', namespaces=config.namespaces) if len(principal) != 1: - raise RuntimeError('Referential constraint must contain exactly one principal element') + raise PyODataParserError('Referential constraint must contain exactly one principal element') principal_name = principal[0].get('Role') if principal_name is None: - raise RuntimeError('Principal role name was not specified') + raise PyODataParserError('Principal role name was not specified') principal_refs = [] for property_ref in principal[0].xpath('edm:PropertyRef', namespaces=config.namespaces): principal_refs.append(property_ref.get('Name')) if not principal_refs: - raise RuntimeError('In role {} should be at least one principal property defined'.format(principal_name)) + raise PyODataParserError('In role {} should be at least one principal property defined'.format(principal_name)) dependent = referential_constraint_node.xpath('edm:Dependent', namespaces=config.namespaces) if len(dependent) != 1: - raise RuntimeError('Referential constraint must contain exactly one dependent element') + raise PyODataParserError('Referential constraint must contain exactly one dependent element') dependent_name = dependent[0].get('Role') if dependent_name is None: - raise RuntimeError('Dependent role name was not specified') + raise PyODataParserError('Dependent role name was not specified') dependent_refs = [] for property_ref in dependent[0].xpath('edm:PropertyRef', namespaces=config.namespaces): dependent_refs.append(property_ref.get('Name')) if len(principal_refs) != len(dependent_refs): - raise RuntimeError('Number of properties should be equal for the principal {} and the dependent {}' - .format(principal_name, dependent_name)) + raise PyODataParserError('Number of properties should be equal for the principal {} and the dependent {}' + .format(principal_name, dependent_name)) return ReferentialConstraint( PrincipalRole(principal_name, principal_refs), DependentRole(dependent_name, dependent_refs)) diff --git a/pyodata/v2/elements.py b/pyodata/v2/elements.py index c1bb5fbb..ba924cf0 100644 --- a/pyodata/v2/elements.py +++ b/pyodata/v2/elements.py @@ -150,7 +150,7 @@ def end_by_role(self, end_role): try: return next((item for item in self._end_roles if item.role == end_role)) except StopIteration: - raise KeyError('Association {} has no End with Role {}'.format(self._name, end_role)) + raise PyODataModelError('Association {} has no End with Role {}'.format(self._name, end_role)) @property def referential_constraint(self): @@ -212,7 +212,7 @@ def association_type(self): @association_type.setter def association_type(self, value): if self._association_type is not None: - raise RuntimeError('Cannot replace {} of {} with {}'.format(self._association_type, self, value)) + raise PyODataModelError('Cannot replace {} of {} with {}'.format(self._association_type, self, value)) self._association_type = value @property @@ -231,13 +231,13 @@ def end_by_role(self, end_role): try: return next((end for end in self._end_roles if end.role == end_role)) except StopIteration: - raise KeyError('Association set {} has no End with Role {}'.format(self._name, end_role)) + raise PyODataModelError('Association set {} has no End with Role {}'.format(self._name, end_role)) def end_by_entity_set(self, entity_set): try: return next((end for end in self._end_roles if end.entity_set_name == entity_set)) except StopIteration: - raise KeyError('Association set {} has no End with Entity Set {}'.format(self._name, entity_set)) + raise PyODataModelError('Association set {} has no End with Entity Set {}'.format(self._name, entity_set)) class ReferentialConstraintRole: @@ -282,7 +282,8 @@ def association(self, association_name, namespace=None): try: return self._decls[namespace].associations[association_name] except KeyError: - raise KeyError('Association {} does not exist in namespace {}'.format(association_name, namespace)) + raise PyODataModelError('Association {} does not exist in namespace {}' + .format(association_name, namespace)) for decl in list(self._decls.values()): try: return decl.associations[association_name] @@ -298,13 +299,13 @@ def association_set_by_association(self, association_name, namespace=None): for association_set in list(self._decls[namespace].association_sets.values()): if association_set.association_type.name == association_name: return association_set - raise KeyError('Association Set for Association {} does not exist in Schema Namespace {}'.format( + raise PyODataModelError('Association Set for Association {} does not exist in Schema Namespace {}'.format( association_name, namespace)) for decl in list(self._decls.values()): for association_set in list(decl.association_sets.values()): if association_set.association_type.name == association_name: return association_set - raise KeyError('Association Set for Association {} does not exist in any Schema Namespace'.format( + raise PyODataModelError('Association Set for Association {} does not exist in any Schema Namespace'.format( association_name)) def association_set(self, set_name, namespace=None): @@ -312,7 +313,7 @@ def association_set(self, set_name, namespace=None): try: return self._decls[namespace].association_sets[set_name] except KeyError: - raise KeyError('Association set {} does not exist in namespace {}'.format(set_name, namespace)) + raise PyODataModelError('Association set {} does not exist in namespace {}'.format(set_name, namespace)) for decl in list(self._decls.values()): try: return decl.association_sets[set_name] diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index c81c74ff..c0af4c75 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -18,7 +18,7 @@ from pyodata.model import elements from pyodata.v2 import elements as elements_v2 -from pyodata.exceptions import HttpError, PyODataException, ExpressionError +from pyodata.exceptions import HttpError, PyODataException, ExpressionError, PyODataModelError LOGGER_NAME = 'pyodata.service' @@ -457,11 +457,11 @@ def _build_values(entity_type, entity): for key, val in entity.items(): try: val = entity_type.proprty(key).typ.traits.to_json(val) - except KeyError: + except PyODataModelError: try: nav_prop = entity_type.nav_proprty(key) val = EntityCreateRequest._build_values(nav_prop.typ, val) - except KeyError: + except PyODataModelError: raise PyODataException('Property {} is not declared in {} entity type'.format( key, entity_type.name)) diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index 367e7b19..9e264075 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -111,7 +111,7 @@ def build_schema(config: Config, schema_nodes): eset = build_element(EntitySet, config, entity_set_node=entity_set) eset.entity_type = schema.entity_type(eset.entity_type_info[1], namespace=eset.entity_type_info[0]) decl.entity_sets[eset.name] = eset - except (PyODataParserError, KeyError) as ex: + except (PyODataModelError, PyODataParserError) as ex: config.err_policy(ParserError.ENTITY_SET).resolve(ex) # After all entity sets are parsed resolve the individual bindings among them and entity types @@ -122,7 +122,7 @@ def build_schema(config: Config, schema_nodes): nav_prop_bin.path = schema.entity_type(path_info.type, namespace=path_info.namespace).nav_proprty(path_info.proprty) nav_prop_bin.target = schema.entity_set(nav_prop_bin.target_info) - except (PyODataModelError, KeyError) as ex: + except PyODataModelError as ex: config.err_policy(ParserError.NAVIGATION_PROPERTY_BIDING).resolve(ex) nav_prop_bin.path = NullType(path_info.type) nav_prop_bin.target = NullProperty(nav_prop_bin.target_info) @@ -164,7 +164,7 @@ def build_type_definition(config: Config, node): if annotation_nodes: annotation_node = annotation_nodes[0] build_annotation(annotation_node.get('Term'), config, target=typ, annotation_node=annotation_node) - except KeyError as ex: + except PyODataModelError as ex: config.err_policy(ParserError.TYPE_DEFINITION).resolve(ex) typ = NullType(node.get('Name')) diff --git a/tests/test_model.py b/tests/test_model.py index 4a79e296..07480703 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -3,7 +3,7 @@ from pyodata.config import Config from pyodata.version import ODATAVersion -from pyodata.exceptions import PyODataParserError +from pyodata.exceptions import PyODataParserError, PyODataModelError from pyodata.model.builder import MetadataBuilder from pyodata.model.elements import Schema, Types, Typ from pyodata.v2 import ODataV2 @@ -37,10 +37,11 @@ def primitive_types() -> List[Typ]: ] config = Config(EmptyODATA) - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: Types.from_name('UnsupportedType', config) - assert typ_ex_info.value.args[0] == f'Requested primitive type is not supported in this version of ODATA' + assert typ_ex_info.value.args[0] == f'Requested primitive type UnsupportedType ' \ + f'is not supported in this version of ODATA' assert Types.from_name('Edm.Binary', config).name == 'Edm.Binary' diff --git a/tests/test_model_v2.py b/tests/test_model_v2.py index 3630c851..bec06a92 100644 --- a/tests/test_model_v2.py +++ b/tests/test_model_v2.py @@ -136,11 +136,11 @@ def test_edmx(schema): assert schema.typ('Building', namespace='EXAMPLE_SRV') == schema.complex_type('Building', namespace='EXAMPLE_SRV') # Error handling in the method typ - without namespace - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: assert schema.typ('FooBar') assert typ_ex_info.value.args[0] == 'Type FooBar does not exist in Schema' # Error handling in the method typ - with namespace - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: assert schema.typ('FooBar', namespace='EXAMPLE_SRV') assert typ_ex_info.value.args[0] == 'Type FooBar does not exist in Schema Namespace EXAMPLE_SRV' @@ -154,17 +154,17 @@ def test_schema_entity_sets(schema): assert schema.entity_set('Cities', namespace='EXAMPLE_SRV') is not None # without namespace - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: assert schema.entity_set('FooBar') assert typ_ex_info.value.args[0] == 'EntitySet FooBar does not exist in any Schema Namespace' # with unknown namespace - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: assert schema.entity_set('FooBar', namespace='BLAH') - assert typ_ex_info.value.args[0] == 'EntitySet FooBar does not exist in Schema Namespace BLAH' + assert typ_ex_info.value.args[0] == 'There is no Schema Namespace BLAH' # with namespace - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: assert schema.entity_set('FooBar', namespace='EXAMPLE_SRV') assert typ_ex_info.value.args[0] == 'EntitySet FooBar does not exist in Schema Namespace EXAMPLE_SRV' @@ -234,17 +234,17 @@ def test_edmx_associations(schema): assert str(association_set) == 'AssociationSet(CustomerOrder_AssocSet)' # error handling: without namespace - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: assert schema.association_set_by_association('FooBar') assert typ_ex_info.value.args[0] == 'Association Set for Association FooBar does not exist in any Schema Namespace' # error handling: with unknown namespace - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: assert schema.association_set_by_association('FooBar', namespace='BLAH') assert typ_ex_info.value.args[0] == 'There is no Schema Namespace BLAH' # error handling: with namespace - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: assert schema.association_set_by_association('FooBar', namespace='EXAMPLE_SRV') assert typ_ex_info.value.args[0] == 'Association Set for Association FooBar does not exist in Schema Namespace EXAMPLE_SRV' @@ -627,9 +627,9 @@ def test_annot_v_l_missing_e_s(mock_warning, xml_builder_factory): metadata = MetadataBuilder(xml_builder.serialize(), Config(ODataV2)) - with pytest.raises(RuntimeError) as e_info: + with pytest.raises(PyODataModelError) as e_info: metadata.build() - assert str(e_info.value) == 'Entity Set DataValueHelp for ValueHelper(Dict/Value) does not exist' + assert str(e_info.value) == 'EntitySet DataValueHelp does not exist in Schema Namespace MISSING_ES' metadata.config.set_custom_error_policy({ ParserError.ANNOTATION: PolicyWarning() @@ -637,8 +637,8 @@ def test_annot_v_l_missing_e_s(mock_warning, xml_builder_factory): metadata.build() assert_logging_policy(mock_warning, - 'RuntimeError', - 'Entity Set DataValueHelp for ValueHelper(Dict/Value) does not exist' + 'PyODataModelError', + 'EntitySet DataValueHelp does not exist in Schema Namespace MISSING_ES' ) @@ -679,11 +679,9 @@ def test_annot_v_l_missing_e_t(mock_warning, xml_builder_factory): metadata = MetadataBuilder(xml_builder.serialize(), Config(ODataV2)) - try: + with pytest.raises(PyODataParserError) as e_info: metadata.build() - assert 'Expected' == 'RuntimeError' - except RuntimeError as ex: - assert str(ex) == 'Target Type Dict of ValueHelper(Dict/Value) does not exist' + assert str(e_info.value) == 'Target Type Dict of ValueHelper(Dict/Value) does not exist' metadata.config.set_custom_error_policy({ ParserError.ANNOTATION: PolicyWarning() @@ -691,7 +689,7 @@ def test_annot_v_l_missing_e_t(mock_warning, xml_builder_factory): metadata.build() assert_logging_policy(mock_warning, - 'RuntimeError', + 'PyODataParserError', 'Target Type Dict of ValueHelper(Dict/Value) does not exist' ) @@ -739,7 +737,7 @@ def test_annot_v_l_trgt_inv_prop(mock_warning, mock_resolve, xml_builder_factory metadata = MetadataBuilder(xml_builder.serialize(), Config(ODataV2)) - with pytest.raises(RuntimeError) as typ_ex_info: + with pytest.raises(PyODataParserError) as typ_ex_info: metadata.build() assert typ_ex_info.value.args[0] == 'Target Property NoExisting of EntityType(Dict) as defined in ' \ 'ValueHelper(Dict/NoExisting) does not exist' @@ -759,10 +757,9 @@ def test_annot_v_l_trgt_inv_prop(mock_warning, mock_resolve, xml_builder_factory metadata.build() assert_logging_policy(mock_warning, - 'RuntimeError', + 'PyODataParserError', 'Target Property NoExisting of EntityType(Dict) as defined in ValueHelper(Dict/NoExisting)' - ' does not exist' - ) + ' does not exist') def test_namespace_with_periods(xml_builder_factory): @@ -984,7 +981,7 @@ def test_missing_association_for_navigation_property(xml_builder_factory): metadata = MetadataBuilder(xml_builder.serialize(), Config(ODataV2)) - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: metadata.build() assert typ_ex_info.value.args[0] == 'Association Followers does not exist in namespace EXAMPLE_SRV' @@ -1002,7 +999,7 @@ def test_edmx_association_end_by_role(): assert association.end_by_role(end_from.role) == end_from assert association.end_by_role(end_to.role) == end_to - with pytest.raises(KeyError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: association.end_by_role('Blah') assert typ_ex_info.value.args[0] == 'Association FooBar has no End with Role Blah' @@ -1207,7 +1204,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac xml_builder.add_schema('EXAMPLE_SRV', schema.format('---', value_list_property)) xml = xml_builder.serialize() - with pytest.raises(RuntimeError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: MetadataBuilder(xml, Config(ODataV2)).build() assert typ_ex_info.value.args[0] == 'ValueHelperParameter(Type) of ValueHelper(MasterEntity/Data) points to ' \ @@ -1219,7 +1216,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac )).build() assert_logging_policy(mock_warning, - 'RuntimeError', + 'PyODataModelError', 'ValueHelperParameter(Type) of ValueHelper(MasterEntity/Data) points to ' 'an non existing LocalDataProperty --- of EntityType(MasterEntity)' ) @@ -1229,7 +1226,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac xml_builder.add_schema('EXAMPLE_SRV', schema.format(local_data_property, '---')) xml = xml_builder.serialize() - with pytest.raises(RuntimeError) as typ_ex_info: + with pytest.raises(PyODataModelError) as typ_ex_info: MetadataBuilder(xml, Config(ODataV2)).build() assert typ_ex_info.value.args[0] == 'ValueHelperParameter(---) of ValueHelper(MasterEntity/Data) points to an non ' \ @@ -1241,7 +1238,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac )).build() assert_logging_policy(mock_warning, - 'RuntimeError', + 'PyODataModelError', 'ValueHelperParameter(---) of ValueHelper(MasterEntity/Data) points to an non ' 'existing ValueListProperty --- of EntityType(DataEntity)' ) diff --git a/tests/test_model_v4.py b/tests/test_model_v4.py index cfdaaea6..5f52091a 100644 --- a/tests/test_model_v4.py +++ b/tests/test_model_v4.py @@ -215,7 +215,7 @@ def test_invalid_property_binding_on_entity_set(xml_builder_factory): xml_builder.add_schema('MightySchema', schema.format('Mistake', path, target)) xml = xml_builder.serialize() - with pytest.raises(KeyError) as ex_info: + with pytest.raises(PyODataModelError) as ex_info: MetadataBuilder(xml, Config(ODataV4)).build() assert ex_info.value.args[0] == 'EntityType Mistake does not exist in any Schema Namespace' @@ -230,7 +230,7 @@ def test_invalid_property_binding_on_entity_set(xml_builder_factory): xml_builder.add_schema('MightySchema', schema.format(etype, path, 'Mistake')) xml = xml_builder.serialize() - with pytest.raises(KeyError) as ex_info: + with pytest.raises(PyODataModelError) as ex_info: MetadataBuilder(xml, Config(ODataV4)).build() assert ex_info.value.args[0] == 'EntitySet Mistake does not exist in any Schema Namespace' @@ -269,13 +269,13 @@ def test_enum_parsing(schema_v4): try: schema_v4.enum_type('ThisEnumDoesNotExist') - except KeyError as ex: - assert str(ex) == f'\'EnumType ThisEnumDoesNotExist does not exist in any Schema Namespace\'' + except PyODataModelError as ex: + assert str(ex) == f'EnumType ThisEnumDoesNotExist does not exist in any Schema Namespace' try: schema_v4.enum_type('Country', 'WrongNamespace').USA - except KeyError as ex: - assert str(ex) == '\'EnumType Country does not exist in Schema Namespace WrongNamespace\'' + except PyODataModelError as ex: + assert str(ex) == 'There is no Schema Namespace WrongNamespace' def test_unsupported_enum_underlying_type(xml_builder_factory): diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 4348f2ef..f35a81e2 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pyodata.v2.service -from pyodata.exceptions import PyODataException, HttpError, ExpressionError +from pyodata.exceptions import PyODataException, HttpError, ExpressionError, PyODataModelError from pyodata.v2.service import EntityKey, EntityProxy, GetEntitySetFilter from tests.conftest import assert_request_contains_header @@ -1323,9 +1323,9 @@ def test_get_entity_set_query_filter_property_error(service): request = service.entity_sets.MasterEntities.get_entities() - with pytest.raises(KeyError) as e_info: + with pytest.raises(PyODataModelError) as e_info: assert not request.Foo == 'bar' - assert e_info.value.args[0] == 'Foo' + assert e_info.value.args[0] == 'Property Foo not found on EntityType(MasterEntity)' @responses.activate From 9837b3ac0be9845d1b360c4e2a2458dabbd376c3 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 20 Dec 2019 15:04:06 +0100 Subject: [PATCH 20/36] Add more comprehensive tests for ODATA V4 --- CHANGELOG.md | 1 + dev-requirements.txt | 1 + tests/conftest.py | 162 ++------ tests/metadata_v4.xml | 369 ------------------ tests/{test_model.py => test_base.py} | 12 +- tests/test_model_v4.py | 337 ---------------- tests/v2/__init__.py | 0 tests/v2/conftest.py | 141 +++++++ tests/{ => v2}/metadata.xml | 0 tests/{ => v2}/test_client.py | 0 tests/{test_model_v2.py => v2/test_model.py} | 6 +- .../test_service.py} | 2 +- tests/{ => v2}/test_vendor_sap.py | 0 tests/v4/__init__.py | 0 tests/v4/conftest.py | 36 ++ tests/v4/metadata.template.xml | 16 + tests/v4/metadata.xml | 59 +++ tests/v4/test_build_function_with_policies.py | 201 ++++++++++ tests/v4/test_build_functions.py | 147 +++++++ tests/v4/test_elements.py | 25 ++ tests/v4/test_type_traits.py | 170 ++++++++ 21 files changed, 830 insertions(+), 855 deletions(-) delete mode 100644 tests/metadata_v4.xml rename tests/{test_model.py => test_base.py} (89%) delete mode 100644 tests/test_model_v4.py create mode 100644 tests/v2/__init__.py create mode 100644 tests/v2/conftest.py rename tests/{ => v2}/metadata.xml (100%) rename tests/{ => v2}/test_client.py (100%) rename tests/{test_model_v2.py => v2/test_model.py} (99%) rename tests/{test_service_v2.py => v2/test_service.py} (99%) rename tests/{ => v2}/test_vendor_sap.py (100%) create mode 100644 tests/v4/__init__.py create mode 100644 tests/v4/conftest.py create mode 100644 tests/v4/metadata.template.xml create mode 100644 tests/v4/metadata.xml create mode 100644 tests/v4/test_build_function_with_policies.py create mode 100644 tests/v4/test_build_functions.py create mode 100644 tests/v4/test_elements.py create mode 100644 tests/v4/test_type_traits.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ef31db7e..40aa20e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Add V4 to pyodata cmd interface - Martin Miksik - Permissive parsing for TypeDefinition - Changes all manually raised exception to be child of PyODataException - Martin Miksik +- More comprehensive tests for ODATA V4 - Martin Miksik ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik diff --git a/dev-requirements.txt b/dev-requirements.txt index 57a24d62..4ee28633 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -8,3 +8,4 @@ pytest-cov codecov flake8 sphinx +jinja2 diff --git a/tests/conftest.py b/tests/conftest.py index 08b10368..a6830f52 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,154 +1,36 @@ """PyTest Fixtures""" -import logging import os +from typing import Type import pytest +import jinja2 from pyodata.config import Config +from pyodata.version import ODATAVersion from pyodata.model.builder import MetadataBuilder -from pyodata.v2 import ODataV2 from pyodata.v4 import ODataV4 +from pyodata.v2 import ODataV2 -@pytest.fixture -def metadata_v2(): - return metadata('metadata.xml') - - -@pytest.fixture -def metadata_v4(): - return metadata('metadata_v4.xml') - - -def metadata(file_name: str): - """Example OData metadata""" +def _path_to_file(file_name): path_to_current_file = os.path.realpath(__file__) current_directory = os.path.split(path_to_current_file)[0] - path_to_file = os.path.join(current_directory, file_name) - - return open(path_to_file, 'rb').read() + return os.path.join(current_directory, file_name) @pytest.fixture -def xml_builder_factory(): - """Skeleton OData metadata""" - - class XMLBuilder: - """Helper class for building XML metadata document""" - - # pylint: disable=too-many-instance-attributes,line-too-long - def __init__(self): - self.reference_is_enabled = True - self.data_services_is_enabled = True - self.schema_is_enabled = True - - self.namespaces = { - 'edmx': "http://schemas.microsoft.com/ado/2007/06/edmx", - 'sap': 'http://www.sap.com/Protocols/SAPData', - 'edm': 'http://schemas.microsoft.com/ado/2008/09/edm', - 'm': 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata', - 'd': 'http://schemas.microsoft.com/ado/2007/08/dataservices', - } - - self.custom_edmx_prologue = None - self.custom_edmx_epilogue = None - - self.custom_data_services_prologue = None - self.custom_data_services_epilogue = None - - self._reference = '\n' + \ - '\n' + \ - '\n' - - self._schemas = '' - - def add_schema(self, namespace, xml_definition): - """Add schema element""" - self._schemas += f""""\n""" - self._schemas += "\n" + xml_definition - self._schemas += '\n' - - def serialize(self): - """Returns full metadata XML document""" - result = self._edmx_prologue() - - if self.reference_is_enabled: - result += self._reference - - if self.data_services_is_enabled: - result += self._data_services_prologue() - - if self.schema_is_enabled: - result += self._schemas - - if self.data_services_is_enabled: - result += self._data_services_epilogue() - - result += self._edmx_epilogue() - - return result - - def _edmx_prologue(self): - if self.custom_edmx_prologue: - prologue = self.custom_edmx_prologue - else: - prologue = f"""""" - return prologue - - def _edmx_epilogue(self): - if self.custom_edmx_epilogue: - epilogue = self.custom_edmx_epilogue - else: - epilogue = '\n' - return epilogue - - def _data_services_prologue(self): - if self.custom_data_services_prologue: - prologue = self.custom_data_services_prologue - else: - prologue = '\n' - return prologue - - def _data_services_epilogue(self): - if self.custom_data_services_epilogue: - prologue = self.custom_data_services_epilogue - else: - prologue = '\n' - return prologue - - return XMLBuilder - - -@pytest.fixture -def schema(metadata_v2): - """Parsed metadata""" - - # pylint: disable=redefined-outer-name - - meta = MetadataBuilder( - metadata_v2, - config=Config(ODataV2) - ) - - return meta.build() - - -@pytest.fixture -def schema_v4(metadata_v4): - meta = MetadataBuilder( - metadata_v4, - config=Config(ODataV4) - ) - - return meta.build() - - -def assert_logging_policy(mock_warning, *args): - """Assert if an warning was outputted by PolicyWarning """ - assert logging.Logger.warning is mock_warning - mock_warning.assert_called_with('[%s] %s', *args) - - -def assert_request_contains_header(headers, name, value): - assert name in headers - assert headers[name] == value +def template_builder(): + def _builder(version: Type[ODATAVersion], **kwargs): + if version == ODataV4: + config = Config(ODataV4) + template = 'v4/metadata.template.xml' + else: + config = Config(ODataV2) + template = 'v4/metadata.template.xml' + + with open(_path_to_file(template), 'rb') as metadata_file: + template = jinja2.Template(metadata_file.read().decode("utf-8")) + template = template.render(**kwargs).encode('ascii') + + return MetadataBuilder(template, config=config), config + + return _builder \ No newline at end of file diff --git a/tests/metadata_v4.xml b/tests/metadata_v4.xml deleted file mode 100644 index f8f9087a..00000000 --- a/tests/metadata_v4.xml +++ /dev/null @@ -1,369 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Org.OData.Core.V1.Permission/Read - - - - - - image/jpeg - - - - - - - - - - Org.OData.Core.V1.Permission/Read - - - - - - - - - - - - - - - - - - - - - - Org.OData.Core.V1.Permission/Read - - - - - - - - - - - Org.OData.Core.V1.Permission/Read - - - - - - - - - - - - - - - Org.OData.Core.V1.Permission/Read - - - - - - - - - - - - - - - - - - - - - - - - - - - Org.OData.Core.V1.Permission/Read - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Org.OData.Capabilities.V1.SearchExpressions/none - - - - - - - - - - - - - - - - - - - - - - Concurrency - - - - - - - Org.OData.Capabilities.V1.NavigationType/None - - - - - - - Org.OData.Capabilities.V1.NavigationType/Recursive - - - - - - - - - - - Org.OData.Capabilities.V1.SearchExpressions/none - - - - - - - - - Trips - Friends - - - - - - - - - - - - Org.OData.Capabilities.V1.SearchExpressions/none - - - - - - - - - - - - - - - - - - - Org.OData.Capabilities.V1.SearchExpressions/none - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Org.OData.Capabilities.V1.ConformanceLevelType/Advanced - - - - application/json;odata.metadata=full;IEEE754Compatible=false;odata.streaming=true - application/json;odata.metadata=minimal;IEEE754Compatible=false;odata.streaming=true - application/json;odata.metadata=none;IEEE754Compatible=false;odata.streaming=true - - - - - - - contains - endswith - startswith - length - indexof - substring - tolower - toupper - trim - concat - year - month - day - hour - minute - second - round - floor - ceiling - cast - isof - - - - - - \ No newline at end of file diff --git a/tests/test_model.py b/tests/test_base.py similarity index 89% rename from tests/test_model.py rename to tests/test_base.py index 07480703..4a0a6d87 100644 --- a/tests/test_model.py +++ b/tests/test_base.py @@ -5,11 +5,11 @@ from pyodata.version import ODATAVersion from pyodata.exceptions import PyODataParserError, PyODataModelError from pyodata.model.builder import MetadataBuilder -from pyodata.model.elements import Schema, Types, Typ +from pyodata.model.elements import Schema, Types, Typ, build_element from pyodata.v2 import ODataV2 -def test_from_etree_mixin(metadata_v2): +def test_build_element(): """Test FromEtreeMixin class""" class EmptyODATA(ODATAVersion): @@ -18,12 +18,14 @@ def build_functions(): return {} config = Config(EmptyODATA) - builder = MetadataBuilder(metadata_v2, config=config) + + class TestElement: + pass with pytest.raises(PyODataParserError) as typ_ex_info: - builder.build() + build_element(TestElement, config) - assert typ_ex_info.value.args[0] == f'{Schema.__name__} is unsupported in {config.odata_version.__name__}' + assert typ_ex_info.value.args[0] == f'{TestElement.__name__} is unsupported in {config.odata_version.__name__}' def test_supported_primitive_types(): diff --git a/tests/test_model_v4.py b/tests/test_model_v4.py deleted file mode 100644 index 5f52091a..00000000 --- a/tests/test_model_v4.py +++ /dev/null @@ -1,337 +0,0 @@ -import json -import datetime -import geojson -import pytest - -from pyodata.policies import PolicyIgnore, ParserError -from pyodata.model.builder import MetadataBuilder -from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError -from pyodata.model.elements import Types, TypeInfo, Schema, NullType, EntityType - -from pyodata.config import Config -from tests.conftest import metadata -from pyodata.v4.elements import NavigationTypeProperty, EntitySet, NavigationPropertyBinding, Unit -from pyodata.v4 import ODataV4, NavigationTypeProperty - -def test_type_traits(): - """Test traits""" - # https://docs.oasis-open.org/odata/odata-json-format/v4.01/csprd05/odata-json-format-v4.01-csprd05.html#sec_PrimitiveValue - - config = Config(ODataV4) - - traits = Types.from_name('Edm.Date', config).traits - test_date = datetime.date(2005, 1, 28) - test_date_json = traits.to_json(test_date) - assert test_date_json == '\"2005-01-28\"' - assert test_date == traits.from_json(test_date_json) - - traits = Types.from_name('Edm.TimeOfDay', config).traits - test_time = datetime.time(7, 59, 59) - test_time_json = traits.to_json(test_time) - assert test_time_json == '\"07:59:59\"' - assert test_time == traits.from_json(test_time_json) - - traits = Types.from_name('Edm.DateTimeOffset', config).traits - test_date_time_offset = datetime.datetime(2012, 12, 3, 7, 16, 23, tzinfo=datetime.timezone.utc) - test_date_time_offset_json = traits.to_json(test_date_time_offset) - assert test_date_time_offset_json == '\"2012-12-03T07:16:23Z\"' - assert test_date_time_offset == traits.from_json(test_date_time_offset_json) - assert test_date_time_offset == traits.from_json('\"2012-12-03T07:16:23+00:00\"') - - # serialization of invalid value - with pytest.raises(PyODataModelError) as e_info: - traits.to_literal('xyz') - assert str(e_info.value).startswith('Cannot convert value of type') - - traits = Types.from_name('Edm.Duration', config).traits - - test_duration_json = 'P8MT4H' - test_duration = traits.from_json(test_duration_json) - assert test_duration.month == 8 - assert test_duration.hour == 4 - assert test_duration_json == traits.to_json(test_duration) - - test_duration_json = 'P2Y6M5DT12H35M30S' - test_duration = traits.from_json(test_duration_json) - assert test_duration.year == 2 - assert test_duration.month == 6 - assert test_duration.day == 5 - assert test_duration.hour == 12 - assert test_duration.minute == 35 - assert test_duration.second == 30 - assert test_duration_json == traits.to_json(test_duration) - - # GeoJson Point - - json_point = json.dumps({ - "type": "Point", - "coordinates": [-118.4080, 33.9425] - }) - - traits = Types.from_name('Edm.GeographyPoint', config).traits - point = traits.from_json(json_point) - - assert isinstance(point, geojson.Point) - assert json_point == traits.to_json(point) - - # GeoJson MultiPoint - - json_multi_point = json.dumps({ - "type": "MultiPoint", - "coordinates": [[100.0, 0.0], [101.0, 1.0]] - }) - - traits = Types.from_name('Edm.GeographyMultiPoint', config).traits - multi_point = traits.from_json(json_multi_point) - - assert isinstance(multi_point, geojson.MultiPoint) - assert json_multi_point == traits.to_json(multi_point) - - # GeoJson LineString - - json_line_string = json.dumps({ - "type": "LineString", - "coordinates": [[102.0, 0.0], [103.0, 1.0], [104.0, 0.0], [105.0, 1.0]] - }) - - traits = Types.from_name('Edm.GeographyLineString', config).traits - line_string = traits.from_json(json_line_string) - - assert isinstance(line_string, geojson.LineString) - assert json_line_string == traits.to_json(line_string) - - # GeoJson MultiLineString - - lines = [] - for i in range(10): - lines.append(geojson.utils.generate_random("LineString")['coordinates']) - - multi_line_string = geojson.MultiLineString(lines) - json_multi_line_string = geojson.dumps(multi_line_string) - traits = Types.from_name('Edm.GeographyMultiLineString', config).traits - - assert multi_line_string == traits.from_json(json_multi_line_string) - assert json_multi_line_string == traits.to_json(multi_line_string) - - # GeoJson Polygon - - json_polygon = json.dumps({ - "type": "Polygon", - "coordinates": [ - [[100.0, 0.0], [105.0, 0.0], [100.0, 1.0]], - [[100.2, 0.2], [103.0, 0.2], [100.3, 0.8]] - ] - }) - - traits = Types.from_name('Edm.GeographyPolygon', config).traits - polygon = traits.from_json(json_polygon) - - assert isinstance(polygon, geojson.Polygon) - assert json_polygon == traits.to_json(polygon) - - # GeoJson MultiPolygon - - lines = [] - for i in range(10): - lines.append(geojson.utils.generate_random("Polygon")['coordinates']) - - multi_polygon = geojson.MultiLineString(lines) - json_multi_polygon = geojson.dumps(multi_polygon) - traits = Types.from_name('Edm.GeographyMultiPolygon', config).traits - - assert multi_polygon == traits.from_json(json_multi_polygon) - assert json_multi_polygon == traits.to_json(multi_polygon) - - -def test_schema(metadata_v4): - meta_builder = MetadataBuilder( - metadata_v4, - config=Config(ODataV4) - ) - - meta_builder.build() - - -def test_edmx_navigation_properties(schema_v4): - """Test parsing of navigation properties""" - - entity = schema_v4.entity_type('Person') - assert str(entity) == 'EntityType(Person)' - assert entity.name == 'Person' - - nav_prop = entity.nav_proprty('Friends') - assert str(nav_prop) == 'NavigationTypeProperty(Friends)' - assert repr(nav_prop.typ) == 'Collection(EntityType(Person))' - assert repr(nav_prop.partner) == 'NavigationTypeProperty(Friends)' - - -def test_referential_constraint(schema_v4): - nav_property: NavigationTypeProperty = schema_v4.entity_type('Product').nav_proprty('Category') - assert str(nav_property) == 'NavigationTypeProperty(Category)' - assert repr(nav_property.referential_constraints[0]) == \ - 'ReferentialConstraint(StructTypeProperty(CategoryID), StructTypeProperty(ID))' - - -def test_navigation_property_binding(schema_v4: Schema): - """Test parsing of navigation property bindings on EntitySets""" - eset: EntitySet = schema_v4.entity_set('People') - assert str(eset) == 'EntitySet(People)' - - nav_prop_biding: NavigationPropertyBinding = eset.navigation_property_bindings[0] - assert repr(nav_prop_biding) == "NavigationPropertyBinding(NavigationTypeProperty(Friends), EntitySet(People))" - - -def test_invalid_property_binding_on_entity_set(xml_builder_factory): - """Test parsing of invalid property bindings on EntitySets""" - schema = """ - - - - - - - - - """ - - etype, path, target = 'MightySchema.Person', 'Friends', 'People' - - xml_builder = xml_builder_factory() - xml_builder.add_schema('MightySchema', schema.format(etype, 'Mistake', target)) - xml = xml_builder.serialize() - - with pytest.raises(PyODataModelError) as ex_info: - MetadataBuilder(xml, Config(ODataV4)).build() - assert ex_info.value.args[0] == 'EntityType(Person) does not contain navigation property Mistake' - - try: - MetadataBuilder(xml, Config(ODataV4, custom_error_policies={ - ParserError.NAVIGATION_PROPERTY_BIDING: PolicyIgnore() - })).build() - except BaseException as ex: - raise pytest.fail(f'IgnorePolicy was supposed to silence "{ex}" but it did not.') - - xml_builder = xml_builder_factory() - xml_builder.add_schema('MightySchema', schema.format('Mistake', path, target)) - xml = xml_builder.serialize() - - with pytest.raises(PyODataModelError) as ex_info: - MetadataBuilder(xml, Config(ODataV4)).build() - assert ex_info.value.args[0] == 'EntityType Mistake does not exist in any Schema Namespace' - - try: - MetadataBuilder(xml, Config(ODataV4, custom_error_policies={ - ParserError.ENTITY_SET: PolicyIgnore() - })).build() - except BaseException as ex: - raise pytest.fail(f'IgnorePolicy was supposed to silence "{ex}" but it did not.') - - xml_builder = xml_builder_factory() - xml_builder.add_schema('MightySchema', schema.format(etype, path, 'Mistake')) - xml = xml_builder.serialize() - - with pytest.raises(PyODataModelError) as ex_info: - MetadataBuilder(xml, Config(ODataV4)).build() - assert ex_info.value.args[0] == 'EntitySet Mistake does not exist in any Schema Namespace' - - -def test_enum_parsing(schema_v4): - """Test correct parsing of enum""" - - country = schema_v4.enum_type('Country').USA - assert str(country) == "Country'USA'" - - country2 = schema_v4.enum_type('Country')['USA'] - assert str(country2) == "Country'USA'" - - try: - schema_v4.enum_type('Country').Cyprus - except PyODataException as ex: - assert str(ex) == f'EnumType EnumType(Country) has no member Cyprus' - - c = schema_v4.enum_type('Country')[1] - assert str(c) == "Country'China'" - - try: - schema_v4.enum_type('Country')[15] - except PyODataException as ex: - assert str(ex) == f'EnumType EnumType(Country) has no member with value {15}' - - type_info = TypeInfo(namespace=None, name='Country', is_collection=False) - - try: - schema_v4.get_type(type_info) - except PyODataModelError as ex: - assert str(ex) == f'Neither primitive types nor types parsed from service metadata contain requested type {type_info[0]}' - - language = schema_v4.enum_type('Language') - assert language.is_flags is True - - try: - schema_v4.enum_type('ThisEnumDoesNotExist') - except PyODataModelError as ex: - assert str(ex) == f'EnumType ThisEnumDoesNotExist does not exist in any Schema Namespace' - - try: - schema_v4.enum_type('Country', 'WrongNamespace').USA - except PyODataModelError as ex: - assert str(ex) == 'There is no Schema Namespace WrongNamespace' - - -def test_unsupported_enum_underlying_type(xml_builder_factory): - """Test if parser will parse only allowed underlying types""" - xml_builder = xml_builder_factory() - xml_builder.add_schema('Test', '') - xml = xml_builder.serialize() - - try: - MetadataBuilder(xml, Config(ODataV4)).build() - except PyODataParserError as ex: - assert str(ex).startswith(f'Type Edm.Bool is not valid as underlying type for EnumType - must be one of') - - -def test_enum_value_out_of_range(xml_builder_factory): - """Test if parser will check for values ot of range defined by underlying type""" - xml_builder = xml_builder_factory() - xml_builder.add_schema('Test', """ - - - - """) - xml = xml_builder.serialize() - - try: - MetadataBuilder(xml, Config(ODataV4)).build() - except BaseException as ex: - assert str(ex) == f'Value -130 is out of range for type Edm.Byte' - - -def test_enum_null_type(xml_builder_factory): - """ Test NullType being correctly assigned to invalid types""" - xml_builder = xml_builder_factory() - xml_builder.add_schema('TEST.NAMESPACE', """ - - """) - - metadata = MetadataBuilder( - xml_builder.serialize(), - config=Config( - ODataV4, - default_error_policy=PolicyIgnore() - )) - - schema = metadata.build() - - type_info = TypeInfo(namespace=None, name='MasterEnum', is_collection=False) - assert isinstance(schema.get_type(type_info), NullType) - - -def test_type_definitions(schema_v4): - - type_info = TypeInfo(namespace=None, name='Weight', is_collection=False) - weight = schema_v4.get_type(type_info) - assert isinstance(weight.annotation, Unit) - assert weight.annotation.unit_name == 'Kilograms' - - entity: EntityType = schema_v4.entity_type('Person') - assert entity.proprty('Weight').typ == weight diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py new file mode 100644 index 00000000..1c1ee3bf --- /dev/null +++ b/tests/v2/conftest.py @@ -0,0 +1,141 @@ +"""PyTest Fixtures""" +import logging +import os +import pytest + +from pyodata.config import Config +from pyodata.model.builder import MetadataBuilder +from pyodata.v2 import ODataV2 + + +@pytest.fixture +def metadata_v2(): + return metadata('metadata.xml') + + +def metadata(file_name: str): + """Example OData metadata""" + path_to_current_file = os.path.realpath(__file__) + current_directory = os.path.split(path_to_current_file)[0] + path_to_file = os.path.join(current_directory, file_name) + + return open(path_to_file, 'rb').read() + + +@pytest.fixture +def xml_builder_factory(): + """Skeleton OData metadata""" + + class XMLBuilder: + """Helper class for building XML metadata document""" + + # pylint: disable=too-many-instance-attributes,line-too-long + def __init__(self): + self.reference_is_enabled = True + self.data_services_is_enabled = True + self.schema_is_enabled = True + + self.namespaces = { + 'edmx': "http://schemas.microsoft.com/ado/2007/06/edmx", + 'sap': 'http://www.sap.com/Protocols/SAPData', + 'edm': 'http://schemas.microsoft.com/ado/2008/09/edm', + 'm': 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata', + 'd': 'http://schemas.microsoft.com/ado/2007/08/dataservices', + } + + self.custom_edmx_prologue = None + self.custom_edmx_epilogue = None + + self.custom_data_services_prologue = None + self.custom_data_services_epilogue = None + + self._reference = '\n' + \ + '\n' + \ + '\n' + + self._schemas = '' + + def add_schema(self, namespace, xml_definition): + """Add schema element""" + self._schemas += f""""\n""" + self._schemas += "\n" + xml_definition + self._schemas += '\n' + + def serialize(self): + """Returns full metadata XML document""" + result = self._edmx_prologue() + + if self.reference_is_enabled: + result += self._reference + + if self.data_services_is_enabled: + result += self._data_services_prologue() + + if self.schema_is_enabled: + result += self._schemas + + if self.data_services_is_enabled: + result += self._data_services_epilogue() + + result += self._edmx_epilogue() + + return result + + def _edmx_prologue(self): + if self.custom_edmx_prologue: + prologue = self.custom_edmx_prologue + else: + prologue = f"""""" + return prologue + + def _edmx_epilogue(self): + if self.custom_edmx_epilogue: + epilogue = self.custom_edmx_epilogue + else: + epilogue = '\n' + return epilogue + + def _data_services_prologue(self): + if self.custom_data_services_prologue: + prologue = self.custom_data_services_prologue + else: + prologue = '\n' + return prologue + + def _data_services_epilogue(self): + if self.custom_data_services_epilogue: + prologue = self.custom_data_services_epilogue + else: + prologue = '\n' + return prologue + + return XMLBuilder + + +@pytest.fixture +def schema(metadata_v2): + """Parsed metadata""" + + # pylint: disable=redefined-outer-name + + meta = MetadataBuilder( + metadata_v2, + config=Config(ODataV2) + ) + + return meta.build() + + +def assert_logging_policy(mock_warning, *args): + """Assert if an warning was outputted by PolicyWarning """ + assert logging.Logger.warning is mock_warning + mock_warning.assert_called_with('[%s] %s', *args) + + +def assert_request_contains_header(headers, name, value): + assert name in headers + assert headers[name] == value + + + diff --git a/tests/metadata.xml b/tests/v2/metadata.xml similarity index 100% rename from tests/metadata.xml rename to tests/v2/metadata.xml diff --git a/tests/test_client.py b/tests/v2/test_client.py similarity index 100% rename from tests/test_client.py rename to tests/v2/test_client.py diff --git a/tests/test_model_v2.py b/tests/v2/test_model.py similarity index 99% rename from tests/test_model_v2.py rename to tests/v2/test_model.py index bec06a92..250a358e 100644 --- a/tests/test_model_v2.py +++ b/tests/v2/test_model.py @@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock import pytest -from tests.conftest import assert_logging_policy +from tests.v2.conftest import assert_logging_policy from pyodata.config import Config from pyodata.model.builder import MetadataBuilder from pyodata.model.elements import Typ, Types, EntityType, TypeInfo, NullType, Schema, StructTypeProperty @@ -1229,8 +1229,8 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac with pytest.raises(PyODataModelError) as typ_ex_info: MetadataBuilder(xml, Config(ODataV2)).build() - assert typ_ex_info.value.args[0] == 'ValueHelperParameter(---) of ValueHelper(MasterEntity/Data) points to an non ' \ - 'existing ValueListProperty --- of EntityType(DataEntity)' + assert typ_ex_info.value.args[0] == 'ValueHelperParameter(---) of ValueHelper(MasterEntity/Data) points to ' \ + 'an non existing ValueListProperty --- of EntityType(DataEntity)' MetadataBuilder(xml, Config( ODataV2, diff --git a/tests/test_service_v2.py b/tests/v2/test_service.py similarity index 99% rename from tests/test_service_v2.py rename to tests/v2/test_service.py index f35a81e2..2acf30f8 100644 --- a/tests/test_service_v2.py +++ b/tests/v2/test_service.py @@ -10,7 +10,7 @@ from pyodata.exceptions import PyODataException, HttpError, ExpressionError, PyODataModelError from pyodata.v2.service import EntityKey, EntityProxy, GetEntitySetFilter -from tests.conftest import assert_request_contains_header +from tests.v2.conftest import assert_request_contains_header URL_ROOT = 'http://odatapy.example.com' diff --git a/tests/test_vendor_sap.py b/tests/v2/test_vendor_sap.py similarity index 100% rename from tests/test_vendor_sap.py rename to tests/v2/test_vendor_sap.py diff --git a/tests/v4/__init__.py b/tests/v4/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/v4/conftest.py b/tests/v4/conftest.py new file mode 100644 index 00000000..cd820ecc --- /dev/null +++ b/tests/v4/conftest.py @@ -0,0 +1,36 @@ +"""PyTest Fixtures""" +import pytest + +from pyodata.config import Config +from pyodata.model.builder import MetadataBuilder +from pyodata.v4 import ODataV4, Schema +from tests.conftest import _path_to_file + + +@pytest.fixture +def inline_namespaces(): + return 'xmlns="MyEdm" xmlns:edmx="MyEdmx"' + + +@pytest.fixture +def config(): + return Config(ODataV4, xml_namespaces={ + 'edmx': 'MyEdmx', + 'edm': 'MyEdm' + }) + + +@pytest.fixture +def metadata(): + with open(_path_to_file('v4/metadata.xml'), 'rb') as metadata: + return metadata.read() + + +@pytest.fixture +def schema(metadata) -> Schema: + meta = MetadataBuilder( + metadata, + config=Config(ODataV4) + ) + + return meta.build() diff --git a/tests/v4/metadata.template.xml b/tests/v4/metadata.template.xml new file mode 100644 index 00000000..e1235fe2 --- /dev/null +++ b/tests/v4/metadata.template.xml @@ -0,0 +1,16 @@ + + + + + {% for element in schema_elements %} + {{ element }} + {% endfor %} + + {% for element in entity_container %} + {{ element }} + {% endfor %} + + + + diff --git a/tests/v4/metadata.xml b/tests/v4/metadata.xml new file mode 100644 index 00000000..568fe387 --- /dev/null +++ b/tests/v4/metadata.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/v4/test_build_function_with_policies.py b/tests/v4/test_build_function_with_policies.py new file mode 100644 index 00000000..3bc39a70 --- /dev/null +++ b/tests/v4/test_build_function_with_policies.py @@ -0,0 +1,201 @@ +import pytest +from lxml import etree + +from pyodata.policies import ParserError, PolicyIgnore, PolicyFatal, PolicyWarning +from pyodata.exceptions import PyODataModelError, PyODataParserError +from pyodata.model.elements import build_element, Typ, NullType +from pyodata.v4 import ODataV4 +from pyodata.v4.elements import NullProperty, EnumType + + +class TestFaultySchema: + def test_faulty_property_type(self, template_builder): + faulty_entity = """ + + + """ + + builder, config = template_builder(ODataV4, schema_elements=[faulty_entity]) + with pytest.raises(PyODataModelError) as ex_info: + builder.build() + assert ex_info.value.args[0] == 'Neither primitive types nor types parsed ' \ + 'from service metadata contain requested type Joke' + + config.set_custom_error_policy({ParserError.PROPERTY: PolicyIgnore()}) + assert isinstance(builder.build().entity_type('OData').proprty('why_it_is_so_good').typ, NullType) + + def test_faulty_navigation_properties(self, template_builder): + # Test handling of faulty type + faulty_entity = """ + + + """ + builder, config = template_builder(ODataV4, schema_elements=[faulty_entity]) + + with pytest.raises(PyODataModelError) as ex_info: + builder.build() + assert ex_info.value.args[0] == 'Neither primitive types nor types parsed ' \ + 'from service metadata contain requested type Position' + + config.set_custom_error_policy({ParserError.NAVIGATION_PROPERTY: PolicyIgnore()}) + assert isinstance(builder.build().entity_type('Restaurant').nav_proprty('Location').typ, NullType) + + # Test handling of faulty partner + faulty_entity = """ + + + """ + builder, config = template_builder(ODataV4, schema_elements=[faulty_entity]) + + with pytest.raises(PyODataModelError) as ex_info: + builder.build() + assert ex_info.value.args[0] == 'No navigation property with name "Joke" found in "EntityType(Restaurant)"' + + config.set_custom_error_policy({ParserError.NAVIGATION_PROPERTY: PolicyIgnore()}) + assert isinstance(builder.build().entity_type('Restaurant').nav_proprty('Competitors').partner, NullProperty) + + def test_faulty_referential_constraints(self, template_builder): + airport = """ + + + + """ + + flight = """ + + + + + + """ + + builder, config = template_builder(ODataV4, schema_elements=[airport, flight]) + with pytest.raises(PyODataModelError) as ex_info: + builder.build() + + assert ex_info.value.args[0] == 'Property Name not found on EntityType(Airport)' + config.set_custom_error_policy({ParserError.REFERENTIAL_CONSTRAINT: PolicyIgnore()}) + assert isinstance(builder.build().entity_type('Flight').nav_proprty('To').referential_constraints[0]. + referenced_proprty, NullProperty) + assert isinstance(builder.build().entity_type('Flight').nav_proprty('To').referential_constraints[0].proprty, + NullProperty) + + flight = """ + + + + + + """ + + builder, config = template_builder(ODataV4, schema_elements=[airport, flight]) + with pytest.raises(PyODataModelError) as ex_info: + builder.build() + + assert ex_info.value.args[0] == 'Property Name not found on EntityType(Flight)' + config.set_custom_error_policy({ParserError.REFERENTIAL_CONSTRAINT: PolicyIgnore()}) + assert isinstance(builder.build().entity_type('Flight').nav_proprty('To').referential_constraints[0]. + referenced_proprty, NullProperty) + assert isinstance(builder.build().entity_type('Flight').nav_proprty('To').referential_constraints[0].proprty, + NullProperty) + + def test_faulty_entity_set(self, template_builder, caplog): + airport = """ + + + + + """ + + airports = '' + builder, config = template_builder(ODataV4, schema_elements=[airport], entity_container=[airports]) + with pytest.raises(PyODataModelError) as ex_info: + builder.build() + + assert ex_info.value.args[0] == 'EntityType Port does not exist in Schema Namespace SampleService.Models' + config.set_custom_error_policy({ParserError.ENTITY_SET: PolicyWarning()}) + assert builder.build().entity_sets == [] + assert caplog.messages[-1] == '[PyODataModelError] EntityType Port does not ' \ + 'exist in Schema Namespace SampleService.Models' + + def test_faulty_navigation_property_binding(self, template_builder, caplog): + airport = '' + person = '' + flight = """ + + + """ + airports = '' + people = """ + + + """ + + builder, config = template_builder(ODataV4, schema_elements=[person, airport, flight], + entity_container=[airports, people]) + with pytest.raises(PyODataModelError) as ex_info: + builder.build() + + assert ex_info.value.args[0] == 'EntityType(Flight) does not contain navigation property To' + config.set_custom_error_policy({ParserError.NAVIGATION_PROPERTY_BIDING: PolicyWarning()}) + binding = builder.build().entity_set('People').navigation_property_bindings[0] + assert caplog.messages[-1] == '[PyODataModelError] EntityType(Flight) does not contain navigation property To' + assert isinstance(binding.path, NullType) + assert isinstance(binding.target, NullProperty) + + people = """ + + + """ + + builder, config = template_builder(ODataV4, schema_elements=[person, airport, flight], + entity_container=[airports, people]) + with pytest.raises(PyODataModelError) as ex_info: + builder.build() + + assert ex_info.value.args[0] == 'EntitySet Ports does not exist in any Schema Namespace' + config.set_custom_error_policy({ParserError.NAVIGATION_PROPERTY_BIDING: PolicyWarning()}) + binding = builder.build().entity_set('People').navigation_property_bindings[0] + assert caplog.messages[-1] == '[PyODataModelError] EntitySet Ports does not exist in any Schema Namespace' + assert isinstance(binding.path, NullType) + assert isinstance(binding.target, NullProperty) + + +def test_build_type_definition_faulty_data(config, caplog): + node = etree.fromstring( + ' \n' + ' \n' + ' \n') + + with pytest.raises(PyODataModelError) as ex_info: + build_element(Typ, config, node=node) + assert ex_info.value.args[0] == 'Requested primitive type NonBaseType is not supported in this version of ODATA' + + config.set_custom_error_policy({ParserError.TYPE_DEFINITION: PolicyWarning()}) + assert isinstance(build_element(Typ, config, node=node), NullType) + assert caplog.messages[-1] == '[PyODataModelError] Requested primitive type NonBaseType ' \ + 'is not supported in this version of ODATA' + + +def test_build_enum_type_fault_data(config, inline_namespaces, caplog): + node = etree.fromstring(f'') + with pytest.raises(PyODataParserError) as ex_info: + build_element(EnumType, config, type_node=node, namespace=config.namespaces) + assert ex_info.value.args[0].startswith( + 'Type Edm.Bool is not valid as underlying type for EnumType - must be one of') + + config.set_custom_error_policy({ParserError.ENUM_TYPE: PolicyIgnore()}) + assert isinstance(build_element(EnumType, config, type_node=node, namespace=config.namespaces), NullType) + + node = etree.fromstring(f'' + ' ' + '') + + config.set_custom_error_policy({ParserError.ENUM_TYPE: PolicyFatal()}) + with pytest.raises(PyODataParserError) as ex_info: + build_element(EnumType, config, type_node=node, namespace=config.namespaces) + assert ex_info.value.args[0] == 'Value -1 is out of range for type Edm.Byte' + + config.set_custom_error_policy({ParserError.ENUM_TYPE: PolicyWarning()}) + assert isinstance(build_element(EnumType, config, type_node=node, namespace=config.namespaces), NullType) + assert caplog.messages[-1] == '[PyODataParserError] Value -1 is out of range for type Edm.Byte' diff --git a/tests/v4/test_build_functions.py b/tests/v4/test_build_functions.py new file mode 100644 index 00000000..726c3f33 --- /dev/null +++ b/tests/v4/test_build_functions.py @@ -0,0 +1,147 @@ +import pytest +from lxml import etree + +from pyodata.model.elements import build_element, TypeInfo, Typ, ComplexType, EntityType, StructTypeProperty +from pyodata.model.type_traits import EdmIntTypTraits, EdmBooleanTypTraits +from pyodata.v4 import NavigationTypeProperty, NavigationPropertyBinding +from pyodata.v4.elements import PathInfo, Unit, EntitySet, EnumType + + +class TestSchema: + def test_types(self, schema): + assert isinstance(schema.complex_type('Location'), ComplexType) + assert isinstance(schema.entity_type('Airport'), EntityType) + assert isinstance(schema.enum_type('Gender'), EnumType) + assert isinstance(schema.entity_set('People'), EntitySet) + + def test_property_type(self, schema): + person = schema.entity_type('Person') + assert isinstance(person.proprty('Gender'), StructTypeProperty) + assert repr(person.proprty('Gender').typ) == 'EnumType(Gender)' + assert repr(person.proprty('Weight').typ) == 'Typ(Weight)' + assert repr(person.proprty('AddressInfo').typ) == 'Collection(ComplexType(Location))' + + def test_navigation_properties(self, schema): + person = schema.entity_type('Person') + assert person.nav_proprty('Friends').typ.is_collection is True + assert person.nav_proprty('Friends').typ.item_type == person + assert person.nav_proprty('Friends').partner == person.nav_proprty('Friends') + + def test_referential_constraints(self, schema): + destination_name = schema.entity_type('Flight').nav_proprty('To').referential_constraints[0] + assert destination_name.proprty == schema.entity_type('Flight').proprty('NameOfDestination') + assert destination_name.referenced_proprty == schema.entity_type('Airport').proprty('Name') + + def test_entity_set(self, schema): + person = schema.entity_type('Person') + people = schema.entity_set('People') + assert people.entity_type == person + + def test_navigation_property_binding(self, schema): + person = schema.entity_type('Person') + people = schema.entity_set('People') + assert people.entity_type == person + bindings = people.navigation_property_bindings + + # test bindings with simple path/target + assert bindings[0].path == person.nav_proprty('Friends') + assert bindings[0].target == people + + # test bindings with complex path/target + assert bindings[1].path == schema.entity_type('Flight').nav_proprty('From') + assert bindings[1].target == schema.entity_set('Airports') + + +def test_build_navigation_type_property(config, inline_namespaces): + node = etree.fromstring( + f'' + ' ' + '' + ) + navigation_type_property = build_element(NavigationTypeProperty, config, node=node) + + assert navigation_type_property.name == 'Friends' + assert navigation_type_property.type_info == TypeInfo('MySpace', 'Person', True) + assert navigation_type_property.partner_info == TypeInfo(None, 'Friends', False) + + assert navigation_type_property.referential_constraints[0].proprty_name == 'FriendID' + assert navigation_type_property.referential_constraints[0].referenced_proprty_name == 'ID' + + +def test_build_navigation_property_binding(config): + + et_info = TypeInfo('SampleService', 'Person', False) + node = etree.fromstring('') + navigation_property_binding = build_element(NavigationPropertyBinding, config, node=node, et_info=et_info) + assert navigation_property_binding.path_info == PathInfo('SampleService', 'Person', 'Friends') + assert navigation_property_binding.target_info == 'People' + + node = etree.fromstring( + '' + ) + navigation_property_binding = build_element(NavigationPropertyBinding, config, node=node, et_info=et_info) + assert navigation_property_binding.path_info == PathInfo('SampleService', 'Flight', 'Airline') + assert navigation_property_binding.target_info == 'Airlines' + + +def test_build_unit_annotation(config): + # Let's think about how to test side effectsite + pass + + +def test_build_type_definition(config, inline_namespaces): + node = etree.fromstring('') + + type_definition = build_element(Typ, config, node=node) + assert type_definition.is_collection is False + assert type_definition.kind == Typ.Kinds.Primitive + assert type_definition.name == 'IsHuman' + assert isinstance(type_definition.traits, EdmBooleanTypTraits) + + node = etree.fromstring( + f'' + ' ' + '' + ) + + type_definition = build_element(Typ, config, node=node) + assert type_definition.kind == Typ.Kinds.Primitive + assert type_definition.name == 'Weight' + assert isinstance(type_definition.traits, EdmIntTypTraits) + assert isinstance(type_definition.annotation, Unit) + assert type_definition.annotation.unit_name == 'Kilograms' + + +def test_build_entity_set_with_v4_builder(config, inline_namespaces): + entity_set_node = etree.fromstring( + f'' + ' ' + '' + ) + + entity_set = build_element(EntitySet, config, entity_set_node=entity_set_node) + assert entity_set.name == 'People' + assert entity_set.entity_type_info == TypeInfo('SampleService', 'Person', False) + assert entity_set.navigation_property_bindings[0].path_info == PathInfo('SampleService', 'Person', 'Friends') + + +def test_build_enum_type(config, inline_namespaces): + node = etree.fromstring(f'' + ' ' + ' ' + '') + + enum = build_element(EnumType, config, type_node=node, namespace=config.namespaces) + assert enum._underlying_type.name == 'Edm.Int32' + assert enum.Male.value == 0 + assert enum.Male.name == "Male" + assert enum['Male'] == enum.Male + assert enum.Female == enum[1] + + node = etree.fromstring(f' {inline_namespaces}' + ' ' + ' ' + '') + enum = build_element(EnumType, config, type_node=node, namespace=config.namespaces) + assert enum._underlying_type.name == 'Edm.Int16' + assert enum.is_flags is True diff --git a/tests/v4/test_elements.py b/tests/v4/test_elements.py new file mode 100644 index 00000000..ed0378cb --- /dev/null +++ b/tests/v4/test_elements.py @@ -0,0 +1,25 @@ +import pytest + +from pyodata.exceptions import PyODataException +from pyodata.v4.type_traits import EnumTypTrait + + +def test_enum_type(schema): + gender = schema.enum_type('Gender') + + assert isinstance(gender.traits, EnumTypTrait) + assert gender.is_flags is False + assert gender.namespace == 'Microsoft.OData.SampleService.Models.TripPin' + + assert str(gender.Male) == "Gender'Male'" + assert str(gender['Male']) == "Gender'Male'" + assert str(gender[1]) == "Gender'Female'" + assert gender.Male.parent == gender + + with pytest.raises(PyODataException) as ex_info: + cat = gender.Cat + assert ex_info.value.args[0] == 'EnumType EnumType(Gender) has no member Cat' + + with pytest.raises(PyODataException) as ex_info: + who_knows = gender[15] + assert ex_info.value.args[0] == 'EnumType EnumType(Gender) has no member with value 15' diff --git a/tests/v4/test_type_traits.py b/tests/v4/test_type_traits.py new file mode 100644 index 00000000..29a97f3d --- /dev/null +++ b/tests/v4/test_type_traits.py @@ -0,0 +1,170 @@ +import datetime +import json +import geojson +import pytest + +from pyodata.exceptions import PyODataModelError +from pyodata.model.elements import Types + + +def test_emd_date_type_traits(config): + traits = Types.from_name('Edm.Date', config).traits + test_date = datetime.date(2005, 1, 28) + test_date_json = traits.to_json(test_date) + assert test_date_json == '\"2005-01-28\"' + assert test_date == traits.from_json(test_date_json) + + assert traits.from_literal(None) is None + + with pytest.raises(PyODataModelError) as ex_info: + traits.from_literal('---') + assert ex_info.value.args[0] == f'Cannot decode date from value ---.' + + with pytest.raises(PyODataModelError) as ex_info: + traits.to_literal('...') + assert ex_info.value.args[0] == f'Cannot convert value of type {type("...")} to literal. Date format is required.' + + +def test_edm_time_of_day_type_trats(config): + traits = Types.from_name('Edm.TimeOfDay', config).traits + test_time = datetime.time(7, 59, 59) + test_time_json = traits.to_json(test_time) + assert test_time_json == '\"07:59:59\"' + assert test_time == traits.from_json(test_time_json) + + assert traits.from_literal(None) is None + + with pytest.raises(PyODataModelError) as ex_info: + traits.from_literal('---') + assert ex_info.value.args[0] == f'Cannot decode date from value ---.' + + with pytest.raises(PyODataModelError) as ex_info: + traits.to_literal('...') + assert ex_info.value.args[0] == f'Cannot convert value of type {type("...")} to literal. Time format is required.' + + +def test_edm_date_time_offset_type_trats(config): + traits = Types.from_name('Edm.DateTimeOffset', config).traits + test_date_time_offset = datetime.datetime(2012, 12, 3, 7, 16, 23, tzinfo=datetime.timezone.utc) + test_date_time_offset_json = traits.to_json(test_date_time_offset) + assert test_date_time_offset_json == '\"2012-12-03T07:16:23Z\"' + assert test_date_time_offset == traits.from_json(test_date_time_offset_json) + assert test_date_time_offset == traits.from_json('\"2012-12-03T07:16:23+00:00\"') + + # serialization of invalid value + with pytest.raises(PyODataModelError) as e_info: + traits.to_literal('xyz') + assert str(e_info.value).startswith('Cannot convert value of type') + + test_date_time_offset = datetime.datetime(2012, 12, 3, 7, 16, 23, tzinfo=None) + with pytest.raises(PyODataModelError) as e_info: + traits.to_literal(test_date_time_offset) + assert str(e_info.value).startswith('Datetime pass without explicitly setting timezone') + + with pytest.raises(PyODataModelError) as ex_info: + traits.from_json('...') + assert str(ex_info.value).startswith('Cannot decode datetime from') + +def test_edm_duration_type_trats(config): + traits = Types.from_name('Edm.Duration', config).traits + + test_duration_json = 'P8MT4H' + test_duration = traits.from_json(test_duration_json) + assert test_duration.month == 8 + assert test_duration.hour == 4 + assert test_duration_json == traits.to_json(test_duration) + + test_duration_json = 'P2Y6M5DT12H35M30S' + test_duration = traits.from_json(test_duration_json) + assert test_duration.year == 2 + assert test_duration.month == 6 + assert test_duration.day == 5 + assert test_duration.hour == 12 + assert test_duration.minute == 35 + assert test_duration.second == 30 + assert test_duration_json == traits.to_json(test_duration) + + with pytest.raises(PyODataModelError) as ex_info: + traits.to_literal("...") + assert ex_info.value.args[0] == f'Cannot convert value of type {type("...")}. Duration format is required.' + + +def test_edm_geo_type_traits(config): + json_point = json.dumps({ + "type": "Point", + "coordinates": [-118.4080, 33.9425] + }) + + traits = Types.from_name('Edm.GeographyPoint', config).traits + point = traits.from_json(json_point) + + assert isinstance(point, geojson.Point) + assert json_point == traits.to_json(point) + + # GeoJson MultiPoint + + json_multi_point = json.dumps({ + "type": "MultiPoint", + "coordinates": [[100.0, 0.0], [101.0, 1.0]] + }) + + traits = Types.from_name('Edm.GeographyMultiPoint', config).traits + multi_point = traits.from_json(json_multi_point) + + assert isinstance(multi_point, geojson.MultiPoint) + assert json_multi_point == traits.to_json(multi_point) + + # GeoJson LineString + + json_line_string = json.dumps({ + "type": "LineString", + "coordinates": [[102.0, 0.0], [103.0, 1.0], [104.0, 0.0], [105.0, 1.0]] + }) + + traits = Types.from_name('Edm.GeographyLineString', config).traits + line_string = traits.from_json(json_line_string) + + assert isinstance(line_string, geojson.LineString) + assert json_line_string == traits.to_json(line_string) + + # GeoJson MultiLineString + + lines = [] + for i in range(10): + lines.append(geojson.utils.generate_random("LineString")['coordinates']) + + multi_line_string = geojson.MultiLineString(lines) + json_multi_line_string = geojson.dumps(multi_line_string) + traits = Types.from_name('Edm.GeographyMultiLineString', config).traits + + assert multi_line_string == traits.from_json(json_multi_line_string) + assert json_multi_line_string == traits.to_json(multi_line_string) + + # GeoJson Polygon + + json_polygon = json.dumps({ + "type": "Polygon", + "coordinates": [ + [[100.0, 0.0], [105.0, 0.0], [100.0, 1.0]], + [[100.2, 0.2], [103.0, 0.2], [100.3, 0.8]] + ] + }) + + traits = Types.from_name('Edm.GeographyPolygon', config).traits + polygon = traits.from_json(json_polygon) + + assert isinstance(polygon, geojson.Polygon) + assert json_polygon == traits.to_json(polygon) + + # GeoJson MultiPolygon + + lines = [] + for i in range(10): + lines.append(geojson.utils.generate_random("Polygon")['coordinates']) + + multi_polygon = geojson.MultiLineString(lines) + json_multi_polygon = geojson.dumps(multi_polygon) + traits = Types.from_name('Edm.GeographyMultiPolygon', config).traits + + assert multi_polygon == traits.from_json(json_multi_polygon) + assert json_multi_polygon == traits.to_json(multi_polygon) \ No newline at end of file From 75100cbab40f435a67cbd81f6c70dbe3df42cc8d Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Thu, 2 Jan 2020 13:02:11 +0100 Subject: [PATCH 21/36] Change parsing of path values for navigation property bindings Target Path can be (and is) bit more complex than just namespace and target name http://docs.oasis-open.org/odata/odata-csdl-xml/v4.01/odata-csdl-xml-v4.01.html#sec_IdentifierandPathValues --- CHANGELOG.md | 1 + pyodata/model/elements.py | 19 +++++++++++++------ pyodata/v4/build_functions.py | 31 +++++++++++++++++++++++-------- pyodata/v4/elements.py | 20 +++----------------- tests/v4/test_build_functions.py | 17 ++++++++++------- 5 files changed, 50 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40aa20e1..fc57f946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Implementation and naming schema of `from_etree` - Martin Miksik - Build functions of struct types now handle invalid metadata independently. - Martin Miksik - Default value of precision if non is provided in metadata - Martin Miksik +- Parsing of path values for navigation property bindings - Martin Miksik ### Fixed - make sure configured error policies are applied for Annotations referencing diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 4459de46..28186c68 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -121,12 +121,19 @@ def name(self): @staticmethod def parse(value): - parts = value.split('.') - - if len(parts) == 1: - return IdentifierInfo(None, value) - - return IdentifierInfo('.'.join(parts[:-1]), parts[-1]) + segments = value.split('/') + path = [] + for segment in segments: + parts = segment.split('.') + + if len(parts) == 1: + path.append(IdentifierInfo(None, parts[-1])) + else: + path.append(IdentifierInfo('.'.join(parts[:-1]), parts[-1])) + + if len(path) == 1: + return path[0] + return path class Types: diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index 9e264075..bd0ce8af 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -10,10 +10,10 @@ from pyodata.exceptions import PyODataParserError, PyODataModelError from pyodata.model.build_functions import build_entity_set from pyodata.model.elements import ComplexType, Schema, NullType, build_element, EntityType, Types, \ - StructTypeProperty, build_annotation, Typ + StructTypeProperty, build_annotation, Typ, Identifier from pyodata.policies import ParserError from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint, \ - NavigationPropertyBinding, to_path_info, EntitySet, Unit, EnumMember, EnumType + NavigationPropertyBinding, EntitySet, Unit, EnumMember, EnumType # pylint: disable=protected-access,too-many-locals,too-many-branches,too-many-statements @@ -115,16 +115,29 @@ def build_schema(config: Config, schema_nodes): config.err_policy(ParserError.ENTITY_SET).resolve(ex) # After all entity sets are parsed resolve the individual bindings among them and entity types + entity_set: EntitySet for entity_set in schema.entity_sets: + nav_prop_bin: NavigationPropertyBinding for nav_prop_bin in entity_set.navigation_property_bindings: - path_info = nav_prop_bin.path_info try: - nav_prop_bin.path = schema.entity_type(path_info.type, - namespace=path_info.namespace).nav_proprty(path_info.proprty) - nav_prop_bin.target = schema.entity_set(nav_prop_bin.target_info) + identifiers = nav_prop_bin.path_info + entity_identifier = identifiers[0] if isinstance(identifiers, list) else entity_set.entity_type_info + entity = schema.entity_type(entity_identifier.name, namespace=entity_identifier.namespace) + name = identifiers[-1].name if isinstance(identifiers, list) else identifiers.name + nav_prop_bin.path = entity.nav_proprty(name) + + identifiers = nav_prop_bin.target_info + if isinstance(identifiers, list): + name = identifiers[-1].name + namespace = identifiers[-1].namespace + else: + name = identifiers.name + namespace = identifiers.namespace + + nav_prop_bin.target = schema.entity_set(name, namespace) except PyODataModelError as ex: config.err_policy(ParserError.NAVIGATION_PROPERTY_BIDING).resolve(ex) - nav_prop_bin.path = NullType(path_info.type) + nav_prop_bin.path = NullType(nav_prop_bin.path_info[-1].name) nav_prop_bin.target = NullProperty(nav_prop_bin.target_info) # TODO: Finally, process Annotation nodes when all Scheme nodes are completely processed. @@ -148,7 +161,9 @@ def build_navigation_type_property(config: Config, node): def build_navigation_property_binding(config: Config, node, et_info): - return NavigationPropertyBinding(to_path_info(node.get('Path'), et_info), node.get('Target')) + # return NavigationPropertyBinding(to_path_info(node.get('Path'), et_info), node.get('Target')) + + return NavigationPropertyBinding(Identifier.parse(node.get('Path')), Identifier.parse(node.get('Target'))) def build_unit_annotation(config: Config, target: Typ, annotation_node): diff --git a/pyodata/v4/elements.py b/pyodata/v4/elements.py index e45271ef..f03d2f97 100644 --- a/pyodata/v4/elements.py +++ b/pyodata/v4/elements.py @@ -1,26 +1,12 @@ """ Repository of elements specific to the ODATA V4""" from typing import Optional, List -import collections - from pyodata.model import elements from pyodata.exceptions import PyODataModelError, PyODataException -from pyodata.model.elements import VariableDeclaration, StructType, TypeInfo, Annotation, Identifier +from pyodata.model.elements import VariableDeclaration, StructType, Annotation, Identifier, IdentifierInfo from pyodata.model.type_traits import TypTraits from pyodata.v4.type_traits import EnumTypTrait -PathInfo = collections.namedtuple('PathInfo', 'namespace type proprty') - - -def to_path_info(value: str, et_info: TypeInfo): - """ Helper function for parsing Path attribute on NavigationPropertyBinding property """ - if '/' in value: - parts = value.split('.') - entity_name, property_name = parts[-1].split('/') - return PathInfo('.'.join(parts[:-1]), entity_name, property_name) - - return PathInfo(et_info.namespace, et_info.name, value) - class NullProperty: """ Defines fallback class when parser is unable to process property defined in xml """ @@ -114,7 +100,7 @@ class NavigationPropertyBinding: https://docs.oasis-open.org/odata/odata-csdl-xml/v4.01/csprd06/odata-csdl-xml-v4.01-csprd06.html#sec_NavigationPropertyBinding """ - def __init__(self, path_info: PathInfo, target_info: str): + def __init__(self, path_info: [IdentifierInfo], target_info: str): self._path_info = path_info self._target_info = target_info self._path: Optional[NavigationTypeProperty] = None @@ -127,7 +113,7 @@ def __str__(self): return f"{self.__class__.__name__}({self.path}, {self.target})" @property - def path_info(self) -> PathInfo: + def path_info(self) -> [IdentifierInfo]: return self._path_info @property diff --git a/tests/v4/test_build_functions.py b/tests/v4/test_build_functions.py index 726c3f33..d55b2c83 100644 --- a/tests/v4/test_build_functions.py +++ b/tests/v4/test_build_functions.py @@ -1,10 +1,11 @@ import pytest from lxml import etree -from pyodata.model.elements import build_element, TypeInfo, Typ, ComplexType, EntityType, StructTypeProperty +from pyodata.model.elements import build_element, TypeInfo, Typ, ComplexType, EntityType, StructTypeProperty, \ + IdentifierInfo from pyodata.model.type_traits import EdmIntTypTraits, EdmBooleanTypTraits from pyodata.v4 import NavigationTypeProperty, NavigationPropertyBinding -from pyodata.v4.elements import PathInfo, Unit, EntitySet, EnumType +from pyodata.v4.elements import Unit, EntitySet, EnumType class TestSchema: @@ -73,15 +74,17 @@ def test_build_navigation_property_binding(config): et_info = TypeInfo('SampleService', 'Person', False) node = etree.fromstring('') navigation_property_binding = build_element(NavigationPropertyBinding, config, node=node, et_info=et_info) - assert navigation_property_binding.path_info == PathInfo('SampleService', 'Person', 'Friends') - assert navigation_property_binding.target_info == 'People' + assert navigation_property_binding.path_info == IdentifierInfo(None, 'Friends') + assert navigation_property_binding.target_info == IdentifierInfo(None, 'People') node = etree.fromstring( '' ) navigation_property_binding = build_element(NavigationPropertyBinding, config, node=node, et_info=et_info) - assert navigation_property_binding.path_info == PathInfo('SampleService', 'Flight', 'Airline') - assert navigation_property_binding.target_info == 'Airlines' + assert navigation_property_binding.path_info == [ + IdentifierInfo('SampleService', 'Flight'), + IdentifierInfo(None, 'Airline')] + assert navigation_property_binding.target_info == IdentifierInfo(None, 'Airlines') def test_build_unit_annotation(config): @@ -122,7 +125,7 @@ def test_build_entity_set_with_v4_builder(config, inline_namespaces): entity_set = build_element(EntitySet, config, entity_set_node=entity_set_node) assert entity_set.name == 'People' assert entity_set.entity_type_info == TypeInfo('SampleService', 'Person', False) - assert entity_set.navigation_property_bindings[0].path_info == PathInfo('SampleService', 'Person', 'Friends') + assert entity_set.navigation_property_bindings[0].path_info == IdentifierInfo(None, 'Friends') def test_build_enum_type(config, inline_namespaces): From b6d1081763e19820e8e89e3d5e1f7378ddddbe7a Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 3 Jan 2020 13:31:52 +0100 Subject: [PATCH 22/36] Fix error when printing navigation property without partner value - Martin Miksik --- CHANGELOG.md | 1 + bin/pyodata | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc57f946..4c53bde3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Import error while using python version prior to 3.7 - Martin Miksik - Parsing datetime containing timezone information for python 3.6 and lower - Martin Miksik - Type hinting for ErrorPolicy's children - Martin Miksik +- Error when printing navigation property without partner value - Martin Miksik ## [1.3.0] diff --git a/bin/pyodata b/bin/pyodata index 4351ec3d..8082dc7f 100755 --- a/bin/pyodata +++ b/bin/pyodata @@ -49,8 +49,10 @@ def print_out_metadata_info(args, client): if client.schema.config.odata_version == ODataV2: print(f' + {prop.name}({prop.to_role.entity_type_name})') else: - print(f' + {prop.name}({prop.partner.name})') - + if prop.partner: + print(f' + {prop.name}({prop.partner.name})') + else: + print(f' + {prop.name}') for fs in client.schema.function_imports: print(f'{fs.http_method} {fs.name}') From 2e0d451e88672d67629834a71a73154cb9e7f81a Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 3 Jan 2020 13:34:04 +0100 Subject: [PATCH 23/36] wip - I need to test the dockerfile on another machine --- Makefile | 23 +- tests/olingo_server/Dockerfile | 12 + tests/olingo_server/pom.xml | 60 +++ .../olingo/server/sample/CarsServlet.java | 67 ++++ .../server/sample/data/DataProvider.java | 195 +++++++++ .../sample/edmprovider/CarsEdmProvider.java | 183 +++++++++ .../sample/processor/CarsProcessor.java | 374 ++++++++++++++++++ .../src/main/resources/META-INF/LICENSE | 331 ++++++++++++++++ .../main/resources/simplelogger.properties | 20 + .../src/main/version/version.html | 37 ++ .../src/main/webapp/WEB-INF/web.xml | 42 ++ .../src/main/webapp/css/olingo.css | 91 +++++ .../src/main/webapp/img/OlingoOrangeTM.png | Bin 0 -> 113360 bytes tests/olingo_server/src/main/webapp/index.jsp | 56 +++ 14 files changed, 1490 insertions(+), 1 deletion(-) create mode 100644 tests/olingo_server/Dockerfile create mode 100644 tests/olingo_server/pom.xml create mode 100644 tests/olingo_server/src/main/java/org/apache/olingo/server/sample/CarsServlet.java create mode 100644 tests/olingo_server/src/main/java/org/apache/olingo/server/sample/data/DataProvider.java create mode 100644 tests/olingo_server/src/main/java/org/apache/olingo/server/sample/edmprovider/CarsEdmProvider.java create mode 100644 tests/olingo_server/src/main/java/org/apache/olingo/server/sample/processor/CarsProcessor.java create mode 100644 tests/olingo_server/src/main/resources/META-INF/LICENSE create mode 100644 tests/olingo_server/src/main/resources/simplelogger.properties create mode 100644 tests/olingo_server/src/main/version/version.html create mode 100644 tests/olingo_server/src/main/webapp/WEB-INF/web.xml create mode 100644 tests/olingo_server/src/main/webapp/css/olingo.css create mode 100644 tests/olingo_server/src/main/webapp/img/OlingoOrangeTM.png create mode 100644 tests/olingo_server/src/main/webapp/index.jsp diff --git a/Makefile b/Makefile index a39b2ad4..58f69bc4 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ PYTHON_MODULE_FILES=$(shell find $(PYTHON_MODULE) -type f -name '*.py') TESTS_DIR=tests TESTS_UNIT_DIR=$(TESTS_DIR) TESTS_UNIT_FILES=$(shell find $(TESTS_UNIT_DIR) -type f -name '*.py') +TESTS_OLINGO_SERVER=$(TESTS_DIR)/olingo_server PYTHON_BIN=python3 @@ -26,6 +27,9 @@ COVERAGE_HTML_DIR=.htmlcov COVERAGE_HTML_ARGS=$(COVERAGE_REPORT_ARGS) -d $(COVERAGE_HTML_DIR) COVERAGE_REPORT_FILES=$(PYTHON_BINARIES) $(PYTHON_MODULE_FILES) +DOCKER_BIN=docker +DOCKER_NAME=pyodata_olingo + all: check .PHONY=check @@ -56,4 +60,21 @@ doc: .PHONY=clean clean: - rm --preserve-root -rf $(COVERAGE_HTML_DIR) .coverage + rm --preserve-root -rf $(COVERAGE_HTML_DIR) .coverage; true + $(DOCKER_BIN) rmi pyodata_olingo; true + $(DOCKER_BIN) rm --force pyodata_olingo; true + +.PHONY=olingo +build_olingo: + $(DOCKER_BIN) rmi $(DOCKER_NAME); true + $(DOCKER_BIN) build -t $(DOCKER_NAME) $(TESTS_OLINGO_SERVER) + +run_olingo: + $(DOCKER_BIN) run -it -p 8888:8080 --name $(DOCKER_NAME) $(DOCKER_NAME):latest + +stop_olingo: + $(DOCKER_BIN) stop $(DOCKER_NAME) + $(DOCKER_BIN) rm --force $(DOCKER_NAME) + +attach_olingo: + $(DOCKER_BIN) attach $(DOCKER_NAME) \ No newline at end of file diff --git a/tests/olingo_server/Dockerfile b/tests/olingo_server/Dockerfile new file mode 100644 index 00000000..4ed44cec --- /dev/null +++ b/tests/olingo_server/Dockerfile @@ -0,0 +1,12 @@ +FROM maven:3.6.0-jdk-8-alpine AS MAVEN_TOOL_CHAIN +COPY pom.xml /tmp/ +COPY src /tmp/src/ +WORKDIR /tmp/ +RUN mvn clean install + +FROM tomcat:9.0-jre8-alpine +COPY --from=MAVEN_TOOL_CHAIN /tmp/target/odata-server*.war $CATALINA_HOME/webapps/odata-server.war + +EXPOSE 8080 + +CMD ["catalina.sh", "run"] diff --git a/tests/olingo_server/pom.xml b/tests/olingo_server/pom.xml new file mode 100644 index 00000000..e60d4add --- /dev/null +++ b/tests/olingo_server/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + org.apache.olingo + odata-server + war + 1.0 + ${project.artifactId} + + + 4.2.0 + + + + + javax.servlet + servlet-api + 2.5 + provided + + + + org.apache.olingo + odata-server-api + ${odata.version} + + + org.apache.olingo + odata-server-core + ${odata.version} + runtime + + + + org.apache.olingo + odata-commons-api + ${odata.version} + + + org.apache.olingo + odata-commons-core + ${odata.version} + + + + org.slf4j + slf4j-api + 1.7.7 + + + + org.slf4j + slf4j-simple + 1.7.7 + runtime + + + diff --git a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/CarsServlet.java b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/CarsServlet.java new file mode 100644 index 00000000..7812f6a1 --- /dev/null +++ b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/CarsServlet.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.olingo.server.sample; + +import java.io.IOException; +import java.util.ArrayList; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.apache.olingo.commons.api.edmx.EdmxReference; +import org.apache.olingo.server.api.OData; +import org.apache.olingo.server.api.ODataHttpHandler; +import org.apache.olingo.server.api.ServiceMetadata; +import org.apache.olingo.server.sample.data.DataProvider; +import org.apache.olingo.server.sample.edmprovider.CarsEdmProvider; +import org.apache.olingo.server.sample.processor.CarsProcessor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CarsServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + private static final Logger LOG = LoggerFactory.getLogger(CarsServlet.class); + + @Override + protected void service(final HttpServletRequest req, final HttpServletResponse resp) + throws ServletException, IOException { + try { + HttpSession session = req.getSession(true); + DataProvider dataProvider = (DataProvider) session.getAttribute(DataProvider.class.getName()); + if (dataProvider == null) { + dataProvider = new DataProvider(); + session.setAttribute(DataProvider.class.getName(), dataProvider); + LOG.info("Created new data provider."); + } + + OData odata = OData.newInstance(); + ServiceMetadata edm = odata.createServiceMetadata(new CarsEdmProvider(), new ArrayList()); + ODataHttpHandler handler = odata.createHandler(edm); + handler.register(new CarsProcessor(dataProvider)); + handler.process(req, resp); + } catch (RuntimeException e) { + LOG.error("Server Error", e); + throw new ServletException(e); + } + } +} diff --git a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/data/DataProvider.java b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/data/DataProvider.java new file mode 100644 index 00000000..191a588d --- /dev/null +++ b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/data/DataProvider.java @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.olingo.server.sample.data; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.olingo.commons.api.ex.ODataException; +import org.apache.olingo.commons.api.ex.ODataRuntimeException; +import org.apache.olingo.commons.api.data.Entity; +import org.apache.olingo.commons.api.data.EntityCollection; +import org.apache.olingo.commons.api.data.Property; +import org.apache.olingo.commons.api.data.ValueType; +import org.apache.olingo.commons.api.data.ComplexValue; +import org.apache.olingo.commons.api.edm.EdmEntitySet; +import org.apache.olingo.commons.api.edm.EdmEntityType; +import org.apache.olingo.commons.api.edm.EdmPrimitiveType; +import org.apache.olingo.commons.api.edm.EdmPrimitiveTypeException; +import org.apache.olingo.commons.api.edm.EdmProperty; +import org.apache.olingo.server.api.uri.UriParameter; +import org.apache.olingo.server.sample.edmprovider.CarsEdmProvider; + +public class DataProvider { + + private final Map data; + + public DataProvider() { + data = new HashMap(); + data.put("Cars", createCars()); + data.put("Manufacturers", createManufacturers()); + } + + public EntityCollection readAll(EdmEntitySet edmEntitySet) { + return data.get(edmEntitySet.getName()); + } + + public Entity read(final EdmEntitySet edmEntitySet, final List keys) throws DataProviderException { + final EdmEntityType entityType = edmEntitySet.getEntityType(); + final EntityCollection entitySet = data.get(edmEntitySet.getName()); + if (entitySet == null) { + return null; + } else { + try { + for (final Entity entity : entitySet.getEntities()) { + boolean found = true; + for (final UriParameter key : keys) { + final EdmProperty property = (EdmProperty) entityType.getProperty(key.getName()); + final EdmPrimitiveType type = (EdmPrimitiveType) property.getType(); + if (!type.valueToString(entity.getProperty(key.getName()).getValue(), + property.isNullable(), property.getMaxLength(), property.getPrecision(), property.getScale(), + property.isUnicode()) + .equals(key.getText())) { + found = false; + break; + } + } + if (found) { + return entity; + } + } + return null; + } catch (final EdmPrimitiveTypeException e) { + throw new DataProviderException("Wrong key!", e); + } + } + } + + public static class DataProviderException extends ODataException { + private static final long serialVersionUID = 5098059649321796156L; + + public DataProviderException(String message, Throwable throwable) { + super(message, throwable); + } + + public DataProviderException(String message) { + super(message); + } + } + + private EntityCollection createCars() { + EntityCollection entitySet = new EntityCollection(); + Entity el = new Entity() + .addProperty(createPrimitive("Id", 1)) + .addProperty(createPrimitive("Model", "F1 W03")) + .addProperty(createPrimitive("ModelYear", "2012")) + .addProperty(createPrimitive("Price", 189189.43)) + .addProperty(createPrimitive("Currency", "EUR")); + el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 1)); + entitySet.getEntities().add(el); + + el = new Entity() + .addProperty(createPrimitive("Id", 2)) + .addProperty(createPrimitive("Model", "F1 W04")) + .addProperty(createPrimitive("ModelYear", "2013")) + .addProperty(createPrimitive("Price", 199999.99)) + .addProperty(createPrimitive("Currency", "EUR")); + el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 2)); + entitySet.getEntities().add(el); + + el = new Entity() + .addProperty(createPrimitive("Id", 3)) + .addProperty(createPrimitive("Model", "F2012")) + .addProperty(createPrimitive("ModelYear", "2012")) + .addProperty(createPrimitive("Price", 137285.33)) + .addProperty(createPrimitive("Currency", "EUR")); + el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 3)); + entitySet.getEntities().add(el); + + el = new Entity() + .addProperty(createPrimitive("Id", 4)) + .addProperty(createPrimitive("Model", "F2013")) + .addProperty(createPrimitive("ModelYear", "2013")) + .addProperty(createPrimitive("Price", 145285.00)) + .addProperty(createPrimitive("Currency", "EUR")); + el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 4)); + entitySet.getEntities().add(el); + + el = new Entity() + .addProperty(createPrimitive("Id", 5)) + .addProperty(createPrimitive("Model", "F1 W02")) + .addProperty(createPrimitive("ModelYear", "2011")) + .addProperty(createPrimitive("Price", 167189.00)) + .addProperty(createPrimitive("Currency", "EUR")); + el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 5)); + entitySet.getEntities().add(el); + + for (Entity entity:entitySet.getEntities()) { + entity.setType(CarsEdmProvider.ET_CAR.getFullQualifiedNameAsString()); + } + return entitySet; + } + + private EntityCollection createManufacturers() { + EntityCollection entitySet = new EntityCollection(); + + Entity el = new Entity() + .addProperty(createPrimitive("Id", 1)) + .addProperty(createPrimitive("Name", "Star Powered Racing")) + .addProperty(createAddress("Star Street 137", "Stuttgart", "70173", "Germany")); + el.setId(createId(CarsEdmProvider.ES_MANUFACTURER_NAME, 1)); + entitySet.getEntities().add(el); + + el = new Entity() + .addProperty(createPrimitive("Id", 2)) + .addProperty(createPrimitive("Name", "Horse Powered Racing")) + .addProperty(createAddress("Horse Street 1", "Maranello", "41053", "Italy")); + el.setId(createId(CarsEdmProvider.ES_MANUFACTURER_NAME, 2)); + entitySet.getEntities().add(el); + + for (Entity entity:entitySet.getEntities()) { + entity.setType(CarsEdmProvider.ET_MANUFACTURER.getFullQualifiedNameAsString()); + } + return entitySet; + } + + private Property createAddress(final String street, final String city, final String zipCode, final String country) { + ComplexValue complexValue=new ComplexValue(); + List addressProperties = complexValue.getValue(); + addressProperties.add(createPrimitive("Street", street)); + addressProperties.add(createPrimitive("City", city)); + addressProperties.add(createPrimitive("ZipCode", zipCode)); + addressProperties.add(createPrimitive("Country", country)); + return new Property(null, "Address", ValueType.COMPLEX, complexValue); + } + + private Property createPrimitive(final String name, final Object value) { + return new Property(null, name, ValueType.PRIMITIVE, value); + } + + private URI createId(String entitySetName, Object id) { + try { + return new URI(entitySetName + "(" + String.valueOf(id) + ")"); + } catch (URISyntaxException e) { + throw new ODataRuntimeException("Unable to create id for entity: " + entitySetName, e); + } + } +} diff --git a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/edmprovider/CarsEdmProvider.java b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/edmprovider/CarsEdmProvider.java new file mode 100644 index 00000000..628ad7d2 --- /dev/null +++ b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/edmprovider/CarsEdmProvider.java @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.olingo.server.sample.edmprovider; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.olingo.commons.api.ex.ODataException; +import org.apache.olingo.commons.api.edm.EdmPrimitiveTypeKind; +import org.apache.olingo.commons.api.edm.FullQualifiedName; +import org.apache.olingo.commons.api.edm.provider.CsdlAbstractEdmProvider; +import org.apache.olingo.commons.api.edm.provider.CsdlComplexType; +import org.apache.olingo.commons.api.edm.provider.CsdlEntityContainer; +import org.apache.olingo.commons.api.edm.provider.CsdlEntityContainerInfo; +import org.apache.olingo.commons.api.edm.provider.CsdlEntitySet; +import org.apache.olingo.commons.api.edm.provider.CsdlEntityType; +import org.apache.olingo.commons.api.edm.provider.CsdlNavigationProperty; +import org.apache.olingo.commons.api.edm.provider.CsdlNavigationPropertyBinding; +import org.apache.olingo.commons.api.edm.provider.CsdlProperty; +import org.apache.olingo.commons.api.edm.provider.CsdlPropertyRef; +import org.apache.olingo.commons.api.edm.provider.CsdlSchema; + +public class CarsEdmProvider extends CsdlAbstractEdmProvider { + + // Service Namespace + public static final String NAMESPACE = "olingo.odata.sample"; + + // EDM Container + public static final String CONTAINER_NAME = "Container"; + public static final FullQualifiedName CONTAINER_FQN = new FullQualifiedName(NAMESPACE, CONTAINER_NAME); + + // Entity Types Names + public static final FullQualifiedName ET_CAR = new FullQualifiedName(NAMESPACE, "Car"); + public static final FullQualifiedName ET_MANUFACTURER = new FullQualifiedName(NAMESPACE, "Manufacturer"); + + // Complex Type Names + public static final FullQualifiedName CT_ADDRESS = new FullQualifiedName(NAMESPACE, "Address"); + + // Entity Set Names + public static final String ES_CARS_NAME = "Cars"; + public static final String ES_MANUFACTURER_NAME = "Manufacturers"; + + @Override + public CsdlEntityType getEntityType(final FullQualifiedName entityTypeName) throws ODataException { + if (ET_CAR.equals(entityTypeName)) { + return new CsdlEntityType() + .setName(ET_CAR.getName()) + .setKey(Arrays.asList( + new CsdlPropertyRef().setName("Id"))) + .setProperties( + Arrays.asList( + new CsdlProperty().setName("Id").setType(EdmPrimitiveTypeKind.Int16.getFullQualifiedName()), + new CsdlProperty().setName("Model").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), + new CsdlProperty().setName("ModelYear").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()) + .setMaxLength(4), + new CsdlProperty().setName("Price").setType(EdmPrimitiveTypeKind.Decimal.getFullQualifiedName()) + .setScale(2), + new CsdlProperty().setName("Currency").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()) + .setMaxLength(3) + ) + ).setNavigationProperties(Arrays.asList( + new CsdlNavigationProperty().setName("Manufacturer").setType(ET_MANUFACTURER) + ) + ); + + } else if (ET_MANUFACTURER.equals(entityTypeName)) { + return new CsdlEntityType() + .setName(ET_MANUFACTURER.getName()) + .setKey(Arrays.asList( + new CsdlPropertyRef().setName("Id"))) + .setProperties(Arrays.asList( + new CsdlProperty().setName("Id").setType(EdmPrimitiveTypeKind.Int16.getFullQualifiedName()), + new CsdlProperty().setName("Name").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), + new CsdlProperty().setName("Address").setType(CT_ADDRESS)) + ).setNavigationProperties(Arrays.asList( + new CsdlNavigationProperty().setName("Cars").setType(ET_CAR).setCollection(true) + ) + ); + } + + return null; + } + + public CsdlComplexType getComplexType(final FullQualifiedName complexTypeName) throws ODataException { + if (CT_ADDRESS.equals(complexTypeName)) { + return new CsdlComplexType().setName(CT_ADDRESS.getName()).setProperties(Arrays.asList( + new CsdlProperty().setName("Street").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), + new CsdlProperty().setName("City").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), + new CsdlProperty().setName("ZipCode").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), + new CsdlProperty().setName("Country").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()) + )); + } + return null; + } + + @Override + public CsdlEntitySet getEntitySet(final FullQualifiedName entityContainer, final String entitySetName) + throws ODataException { + if (CONTAINER_FQN.equals(entityContainer)) { + if (ES_CARS_NAME.equals(entitySetName)) { + return new CsdlEntitySet() + .setName(ES_CARS_NAME) + .setType(ET_CAR) + .setNavigationPropertyBindings( + Arrays.asList( + new CsdlNavigationPropertyBinding().setPath("Manufacturer").setTarget( + CONTAINER_FQN.getFullQualifiedNameAsString() + "/" + ES_MANUFACTURER_NAME))); + } else if (ES_MANUFACTURER_NAME.equals(entitySetName)) { + return new CsdlEntitySet() + .setName(ES_MANUFACTURER_NAME) + .setType(ET_MANUFACTURER).setNavigationPropertyBindings( + Arrays.asList( + new CsdlNavigationPropertyBinding().setPath("Cars") + .setTarget(CONTAINER_FQN.getFullQualifiedNameAsString() + "/" + ES_CARS_NAME))); + } + } + + return null; + } + + @Override + public List getSchemas() throws ODataException { + List schemas = new ArrayList(); + CsdlSchema schema = new CsdlSchema(); + schema.setNamespace(NAMESPACE); + // EntityTypes + List entityTypes = new ArrayList(); + entityTypes.add(getEntityType(ET_CAR)); + entityTypes.add(getEntityType(ET_MANUFACTURER)); + schema.setEntityTypes(entityTypes); + + // ComplexTypes + List complexTypes = new ArrayList(); + complexTypes.add(getComplexType(CT_ADDRESS)); + schema.setComplexTypes(complexTypes); + + // EntityContainer + schema.setEntityContainer(getEntityContainer()); + schemas.add(schema); + + return schemas; + } + + @Override + public CsdlEntityContainer getEntityContainer() throws ODataException { + CsdlEntityContainer container = new CsdlEntityContainer(); + container.setName(CONTAINER_FQN.getName()); + + // EntitySets + List entitySets = new ArrayList(); + container.setEntitySets(entitySets); + entitySets.add(getEntitySet(CONTAINER_FQN, ES_CARS_NAME)); + entitySets.add(getEntitySet(CONTAINER_FQN, ES_MANUFACTURER_NAME)); + + return container; + } + + @Override + public CsdlEntityContainerInfo getEntityContainerInfo(final FullQualifiedName entityContainerName) + throws ODataException { + if (entityContainerName == null || CONTAINER_FQN.equals(entityContainerName)) { + return new CsdlEntityContainerInfo().setContainerName(CONTAINER_FQN); + } + return null; + } +} diff --git a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/processor/CarsProcessor.java b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/processor/CarsProcessor.java new file mode 100644 index 00000000..530bdf79 --- /dev/null +++ b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/processor/CarsProcessor.java @@ -0,0 +1,374 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.olingo.server.sample.processor; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Locale; + +import org.apache.olingo.commons.api.data.ContextURL; +import org.apache.olingo.commons.api.data.ContextURL.Suffix; +import org.apache.olingo.commons.api.data.Entity; +import org.apache.olingo.commons.api.data.EntityCollection; +import org.apache.olingo.commons.api.data.Property; +import org.apache.olingo.commons.api.edm.EdmComplexType; +import org.apache.olingo.commons.api.edm.EdmEntitySet; +import org.apache.olingo.commons.api.edm.EdmPrimitiveType; +import org.apache.olingo.commons.api.edm.EdmProperty; +import org.apache.olingo.commons.api.format.ContentType; +import org.apache.olingo.commons.api.http.HttpHeader; +import org.apache.olingo.commons.api.http.HttpStatusCode; +import org.apache.olingo.server.api.OData; +import org.apache.olingo.server.api.ODataApplicationException; +import org.apache.olingo.server.api.ODataLibraryException; +import org.apache.olingo.server.api.ODataRequest; +import org.apache.olingo.server.api.ODataResponse; +import org.apache.olingo.server.api.ServiceMetadata; +import org.apache.olingo.server.api.deserializer.DeserializerException; +import org.apache.olingo.server.api.processor.ComplexProcessor; +import org.apache.olingo.server.api.processor.EntityCollectionProcessor; +import org.apache.olingo.server.api.processor.EntityProcessor; +import org.apache.olingo.server.api.processor.PrimitiveProcessor; +import org.apache.olingo.server.api.processor.PrimitiveValueProcessor; +import org.apache.olingo.server.api.serializer.ComplexSerializerOptions; +import org.apache.olingo.server.api.serializer.EntityCollectionSerializerOptions; +import org.apache.olingo.server.api.serializer.EntitySerializerOptions; +import org.apache.olingo.server.api.serializer.ODataSerializer; +import org.apache.olingo.server.api.serializer.PrimitiveSerializerOptions; +import org.apache.olingo.server.api.serializer.SerializerException; +import org.apache.olingo.server.api.uri.UriInfo; +import org.apache.olingo.server.api.uri.UriInfoResource; +import org.apache.olingo.server.api.uri.UriResource; +import org.apache.olingo.server.api.uri.UriResourceEntitySet; +import org.apache.olingo.server.api.uri.UriResourceProperty; +import org.apache.olingo.server.api.uri.queryoption.ExpandOption; +import org.apache.olingo.server.api.uri.queryoption.SelectOption; +import org.apache.olingo.server.sample.data.DataProvider; +import org.apache.olingo.server.sample.data.DataProvider.DataProviderException; + +/** + * This processor will deliver entity collections, single entities as well as properties of an entity. + * This is a very simple example which should give you a rough guideline on how to implement such an processor. + * See the JavaDoc of the server.api interfaces for more information. + */ +public class CarsProcessor implements EntityCollectionProcessor, EntityProcessor, + PrimitiveProcessor, PrimitiveValueProcessor, ComplexProcessor { + + private OData odata; + private final DataProvider dataProvider; + private ServiceMetadata edm; + + // This constructor is application specific and not mandatory for the Olingo library. We use it here to simulate the + // database access + public CarsProcessor(final DataProvider dataProvider) { + this.dataProvider = dataProvider; + } + + @Override + public void init(OData odata, ServiceMetadata edm) { + this.odata = odata; + this.edm = edm; + } + + @Override + public void readEntityCollection(final ODataRequest request, ODataResponse response, final UriInfo uriInfo, + final ContentType requestedContentType) throws ODataApplicationException, SerializerException { + // First we have to figure out which entity set to use + final EdmEntitySet edmEntitySet = getEdmEntitySet(uriInfo.asUriInfoResource()); + + // Second we fetch the data for this specific entity set from the mock database and transform it into an EntitySet + // object which is understood by our serialization + EntityCollection entitySet = dataProvider.readAll(edmEntitySet); + + // Next we create a serializer based on the requested format. This could also be a custom format but we do not + // support them in this example + ODataSerializer serializer = odata.createSerializer(requestedContentType); + + // Now the content is serialized using the serializer. + final ExpandOption expand = uriInfo.getExpandOption(); + final SelectOption select = uriInfo.getSelectOption(); + final String id = request.getRawBaseUri() + "/" + edmEntitySet.getName(); + InputStream serializedContent = serializer.entityCollection(edm, edmEntitySet.getEntityType(), entitySet, + EntityCollectionSerializerOptions.with() + .id(id) + .contextURL(isODataMetadataNone(requestedContentType) ? null : + getContextUrl(edmEntitySet, false, expand, select, null)) + .count(uriInfo.getCountOption()) + .expand(expand).select(select) + .build()).getContent(); + + // Finally we set the response data, headers and status code + response.setContent(serializedContent); + response.setStatusCode(HttpStatusCode.OK.getStatusCode()); + response.setHeader(HttpHeader.CONTENT_TYPE, requestedContentType.toContentTypeString()); + } + + @Override + public void readEntity(final ODataRequest request, ODataResponse response, final UriInfo uriInfo, + final ContentType requestedContentType) throws ODataApplicationException, SerializerException { + // First we have to figure out which entity set the requested entity is in + final EdmEntitySet edmEntitySet = getEdmEntitySet(uriInfo.asUriInfoResource()); + + // Next we fetch the requested entity from the database + Entity entity; + try { + entity = readEntityInternal(uriInfo.asUriInfoResource(), edmEntitySet); + } catch (DataProviderException e) { + throw new ODataApplicationException(e.getMessage(), 500, Locale.ENGLISH); + } + + if (entity == null) { + // If no entity was found for the given key we throw an exception. + throw new ODataApplicationException("No entity found for this key", HttpStatusCode.NOT_FOUND + .getStatusCode(), Locale.ENGLISH); + } else { + // If an entity was found we proceed by serializing it and sending it to the client. + ODataSerializer serializer = odata.createSerializer(requestedContentType); + final ExpandOption expand = uriInfo.getExpandOption(); + final SelectOption select = uriInfo.getSelectOption(); + InputStream serializedContent = serializer.entity(edm, edmEntitySet.getEntityType(), entity, + EntitySerializerOptions.with() + .contextURL(isODataMetadataNone(requestedContentType) ? null : + getContextUrl(edmEntitySet, true, expand, select, null)) + .expand(expand).select(select) + .build()).getContent(); + response.setContent(serializedContent); + response.setStatusCode(HttpStatusCode.OK.getStatusCode()); + response.setHeader(HttpHeader.CONTENT_TYPE, requestedContentType.toContentTypeString()); + } + } + + @Override + public void createEntity(ODataRequest request, ODataResponse response, UriInfo uriInfo, + ContentType requestFormat, ContentType responseFormat) + throws ODataApplicationException, DeserializerException, SerializerException { + throw new ODataApplicationException("Entity create is not supported yet.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + @Override + public void deleteEntity(ODataRequest request, ODataResponse response, UriInfo uriInfo) + throws ODataApplicationException { + throw new ODataApplicationException("Entity delete is not supported yet.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + @Override + public void readPrimitive(ODataRequest request, ODataResponse response, UriInfo uriInfo, ContentType format) + throws ODataApplicationException, SerializerException { + readProperty(response, uriInfo, format, false); + } + + @Override + public void readComplex(ODataRequest request, ODataResponse response, UriInfo uriInfo, ContentType format) + throws ODataApplicationException, SerializerException { + readProperty(response, uriInfo, format, true); + } + + @Override + public void readPrimitiveValue(ODataRequest request, ODataResponse response, UriInfo uriInfo, ContentType format) + throws ODataApplicationException, SerializerException { + // First we have to figure out which entity set the requested entity is in + final EdmEntitySet edmEntitySet = getEdmEntitySet(uriInfo.asUriInfoResource()); + // Next we fetch the requested entity from the database + final Entity entity; + try { + entity = readEntityInternal(uriInfo.asUriInfoResource(), edmEntitySet); + } catch (DataProviderException e) { + throw new ODataApplicationException(e.getMessage(), 500, Locale.ENGLISH); + } + if (entity == null) { + // If no entity was found for the given key we throw an exception. + throw new ODataApplicationException("No entity found for this key", HttpStatusCode.NOT_FOUND + .getStatusCode(), Locale.ENGLISH); + } else { + // Next we get the property value from the entity and pass the value to serialization + UriResourceProperty uriProperty = (UriResourceProperty) uriInfo + .getUriResourceParts().get(uriInfo.getUriResourceParts().size() - 1); + EdmProperty edmProperty = uriProperty.getProperty(); + Property property = entity.getProperty(edmProperty.getName()); + if (property == null) { + throw new ODataApplicationException("No property found", HttpStatusCode.NOT_FOUND + .getStatusCode(), Locale.ENGLISH); + } else { + if (property.getValue() == null) { + response.setStatusCode(HttpStatusCode.NO_CONTENT.getStatusCode()); + } else { + String value = String.valueOf(property.getValue()); + ByteArrayInputStream serializerContent = new ByteArrayInputStream( + value.getBytes(Charset.forName("UTF-8"))); + response.setContent(serializerContent); + response.setStatusCode(HttpStatusCode.OK.getStatusCode()); + response.setHeader(HttpHeader.CONTENT_TYPE, ContentType.TEXT_PLAIN.toContentTypeString()); + } + } + } + } + + private void readProperty(ODataResponse response, UriInfo uriInfo, ContentType contentType, + boolean complex) throws ODataApplicationException, SerializerException { + // To read a property we have to first get the entity out of the entity set + final EdmEntitySet edmEntitySet = getEdmEntitySet(uriInfo.asUriInfoResource()); + Entity entity; + try { + entity = readEntityInternal(uriInfo.asUriInfoResource(), edmEntitySet); + } catch (DataProviderException e) { + throw new ODataApplicationException(e.getMessage(), + HttpStatusCode.INTERNAL_SERVER_ERROR.getStatusCode(), Locale.ENGLISH); + } + + if (entity == null) { + // If no entity was found for the given key we throw an exception. + throw new ODataApplicationException("No entity found for this key", + HttpStatusCode.NOT_FOUND.getStatusCode(), Locale.ENGLISH); + } else { + // Next we get the property value from the entity and pass the value to serialization + UriResourceProperty uriProperty = (UriResourceProperty) uriInfo + .getUriResourceParts().get(uriInfo.getUriResourceParts().size() - 1); + EdmProperty edmProperty = uriProperty.getProperty(); + Property property = entity.getProperty(edmProperty.getName()); + if (property == null) { + throw new ODataApplicationException("No property found", + HttpStatusCode.NOT_FOUND.getStatusCode(), Locale.ENGLISH); + } else { + if (property.getValue() == null) { + response.setStatusCode(HttpStatusCode.NO_CONTENT.getStatusCode()); + } else { + ODataSerializer serializer = odata.createSerializer(contentType); + final ContextURL contextURL = isODataMetadataNone(contentType) ? null : + getContextUrl(edmEntitySet, true, null, null, edmProperty.getName()); + InputStream serializerContent = complex ? + serializer.complex(edm, (EdmComplexType) edmProperty.getType(), property, + ComplexSerializerOptions.with().contextURL(contextURL).build()).getContent() : + serializer.primitive(edm, (EdmPrimitiveType) edmProperty.getType(), property, + PrimitiveSerializerOptions.with() + .contextURL(contextURL) + .scale(edmProperty.getScale()) + .nullable(edmProperty.isNullable()) + .precision(edmProperty.getPrecision()) + .maxLength(edmProperty.getMaxLength()) + .unicode(edmProperty.isUnicode()).build()).getContent(); + response.setContent(serializerContent); + response.setStatusCode(HttpStatusCode.OK.getStatusCode()); + response.setHeader(HttpHeader.CONTENT_TYPE, contentType.toContentTypeString()); + } + } + } + } + + private Entity readEntityInternal(final UriInfoResource uriInfo, final EdmEntitySet entitySet) + throws DataProvider.DataProviderException { + // This method will extract the key values and pass them to the data provider + final UriResourceEntitySet resourceEntitySet = (UriResourceEntitySet) uriInfo.getUriResourceParts().get(0); + return dataProvider.read(entitySet, resourceEntitySet.getKeyPredicates()); + } + + private EdmEntitySet getEdmEntitySet(final UriInfoResource uriInfo) throws ODataApplicationException { + final List resourcePaths = uriInfo.getUriResourceParts(); + /* + * To get the entity set we have to interpret all URI segments + */ + if (!(resourcePaths.get(0) instanceof UriResourceEntitySet)) { + throw new ODataApplicationException("Invalid resource type for first segment.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + /* + * Here we should interpret the whole URI but in this example we do not support navigation so we throw an exception + */ + + final UriResourceEntitySet uriResource = (UriResourceEntitySet) resourcePaths.get(0); + return uriResource.getEntitySet(); + } + + private ContextURL getContextUrl(final EdmEntitySet entitySet, final boolean isSingleEntity, + final ExpandOption expand, final SelectOption select, final String navOrPropertyPath) + throws SerializerException { + + return ContextURL.with().entitySet(entitySet) + .selectList(odata.createUriHelper().buildContextURLSelectList(entitySet.getEntityType(), expand, select)) + .suffix(isSingleEntity ? Suffix.ENTITY : null) + .navOrPropertyPath(navOrPropertyPath) + .build(); + } + + @Override + public void updatePrimitive(final ODataRequest request, final ODataResponse response, + final UriInfo uriInfo, final ContentType requestFormat, + final ContentType responseFormat) + throws ODataApplicationException, DeserializerException, SerializerException { + throw new ODataApplicationException("Primitive property update is not supported yet.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + @Override + public void updatePrimitiveValue(final ODataRequest request, ODataResponse response, + final UriInfo uriInfo, final ContentType requestFormat, final ContentType responseFormat) + throws ODataApplicationException, ODataLibraryException { + throw new ODataApplicationException("Primitive property update is not supported yet.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + @Override + public void deletePrimitive(ODataRequest request, ODataResponse response, UriInfo uriInfo) throws + ODataApplicationException { + throw new ODataApplicationException("Primitive property delete is not supported yet.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + @Override + public void deletePrimitiveValue(final ODataRequest request, ODataResponse response, final UriInfo uriInfo) + throws ODataApplicationException, ODataLibraryException { + throw new ODataApplicationException("Primitive property update is not supported yet.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + @Override + public void updateComplex(final ODataRequest request, final ODataResponse response, + final UriInfo uriInfo, final ContentType requestFormat, + final ContentType responseFormat) + throws ODataApplicationException, DeserializerException, SerializerException { + throw new ODataApplicationException("Complex property update is not supported yet.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + @Override + public void deleteComplex(final ODataRequest request, final ODataResponse response, final UriInfo uriInfo) + throws ODataApplicationException { + throw new ODataApplicationException("Complex property delete is not supported yet.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + @Override + public void updateEntity(final ODataRequest request, final ODataResponse response, + final UriInfo uriInfo, final ContentType requestFormat, + final ContentType responseFormat) + throws ODataApplicationException, DeserializerException, SerializerException { + throw new ODataApplicationException("Entity update is not supported yet.", + HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); + } + + public static boolean isODataMetadataNone(final ContentType contentType) { + return contentType.isCompatible(ContentType.APPLICATION_JSON) + && ContentType.VALUE_ODATA_METADATA_NONE.equalsIgnoreCase( + contentType.getParameter(ContentType.PARAMETER_ODATA_METADATA)); + } +} \ No newline at end of file diff --git a/tests/olingo_server/src/main/resources/META-INF/LICENSE b/tests/olingo_server/src/main/resources/META-INF/LICENSE new file mode 100644 index 00000000..715ff307 --- /dev/null +++ b/tests/olingo_server/src/main/resources/META-INF/LICENSE @@ -0,0 +1,331 @@ +Licenses for TecSvc artifact + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + +From: 'abego Software GmbH, Germany' (http://abego-software.de) - abego +TreeLayout Core (http://code.google.com/p/treelayout/) +org.abego.treelayout:org.abego.treelayout.core:jar:1.0.1 License: BSD 3-Clause +"New" or "Revised" License (BSD-3-Clause) +(http://treelayout.googlecode.com/files/LICENSE.TXT) + +[The "BSD license"] +Copyright (c) 2011, abego Software GmbH, Germany (http://www.abego.org) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +3. Neither the name of the abego Software GmbH nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + +From: 'ANTLR' (http://www.antlr.org) - ANTLR 4 Runtime +(http://www.antlr.org/antlr4-runtime) org.antlr:antlr4-runtime:jar:4.1 License: +The BSD License (http://www.antlr.org/license.html) + +[The BSD License] +Copyright (c) 2012 Terence Parr and Sam Harwell +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. Redistributions in binary + form must reproduce the above copyright notice, this list of conditions and + the following disclaimer in the documentation and/or other materials + provided with the distribution. Neither the name of the author nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +From: 'fasterxml.com' (http://fasterxml.com) - Stax2 API +(http://wiki.fasterxml.com/WoodstoxStax2) +org.codehaus.woodstox:stax2-api:bundle:3.1.4 License: The BSD License +(http://www.opensource.org/licenses/bsd-license.php) + +Copyright (c) 2004-2010, Woodstox Project (http://woodstox.codehaus.org/) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +3. Neither the name of the Woodstox XML Processor nor the names + of its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + +From: 'QOS.ch' (http://www.qos.ch) + - SLF4J API Module (http://www.slf4j.org) org.slf4j:slf4j-api:jar:1.7.7 + License: MIT License (http://www.opensource.org/licenses/mit-license.php) + - SLF4J Simple Binding (http://www.slf4j.org) org.slf4j:slf4j-simple:jar:1.7.7 + License: MIT License (http://www.opensource.org/licenses/mit-license.php) + + +Copyright (c) 2004-2013 QOS.ch + +All rights reserved. Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom +the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/olingo_server/src/main/resources/simplelogger.properties b/tests/olingo_server/src/main/resources/simplelogger.properties new file mode 100644 index 00000000..2a3350c7 --- /dev/null +++ b/tests/olingo_server/src/main/resources/simplelogger.properties @@ -0,0 +1,20 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +org.slf4j.simpleLogger.defaultLogLevel=debug +org.slf4j.simpleLogger.logFile=System.out \ No newline at end of file diff --git a/tests/olingo_server/src/main/version/version.html b/tests/olingo_server/src/main/version/version.html new file mode 100644 index 00000000..7bc2ddd9 --- /dev/null +++ b/tests/olingo_server/src/main/version/version.html @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + +
Version Information
HomeApache Olingo
name${name}
version${version}
timestamp${timestamp}
diff --git a/tests/olingo_server/src/main/webapp/WEB-INF/web.xml b/tests/olingo_server/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..2a263678 --- /dev/null +++ b/tests/olingo_server/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,42 @@ + + + + + Apache Olingo OData 4.0 Sample Service + + + index.jsp + + + + CarsServlet + org.apache.olingo.server.sample.CarsServlet + 1 + + + + CarsServlet + /cars.svc/* + + + diff --git a/tests/olingo_server/src/main/webapp/css/olingo.css b/tests/olingo_server/src/main/webapp/css/olingo.css new file mode 100644 index 00000000..5b9deec0 --- /dev/null +++ b/tests/olingo_server/src/main/webapp/css/olingo.css @@ -0,0 +1,91 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ +body { + font-family: Arial, sans-serif; + font-size: 13px; + line-height: 18px; + color: #8904B1; + background-color: #ffffff; +} + +a { + color: #8904B1; +} + +a:VISITED { + color: #D358F7; +} + +td { + padding: 5px; +} + +h1,h2,h3,h4,h5,h6 { + font-family: inherit; + font-weight: bold; + line-height: 1; + color: #8904B1; +} + +h1 { + font-size: 36px; + line-height: 40px; +} + +h2 { + font-size: 30px; + line-height: 40px; +} + +h3 { + font-size: 24px; + line-height: 40px; +} + +h4 { + font-size: 18px; + line-height: 20px; +} + +h5 { + font-size: 14px; + line-height: 20px; +} + +h6 { + font-size: 12px; + line-height: 20px; +} + +.logo { + float: right; +} + +hr, thead, tfoot { + margin: 9px 0; + border-top: 1px solid #8904B1; + border-bottom: 1px solid #8904B1; +} + +table { border-collapse: collapse; border: 1px solid #8904B1; } + +.version { + font-family: "Courier New", monospace; + font-size: 10px; +} \ No newline at end of file diff --git a/tests/olingo_server/src/main/webapp/img/OlingoOrangeTM.png b/tests/olingo_server/src/main/webapp/img/OlingoOrangeTM.png new file mode 100644 index 0000000000000000000000000000000000000000..4878e8a5c258ae1c1dc48f5f10a4268e63b11756 GIT binary patch literal 113360 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 z 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 l4l(!&7fD4BDjE+v$*n&XV(>iU=#u37Izu> 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 Um`B3+@#Fgv6OtCJ=GXQAKeK)?8UO$Q literal 0 HcmV?d00001 diff --git a/tests/olingo_server/src/main/webapp/index.jsp b/tests/olingo_server/src/main/webapp/index.jsp new file mode 100644 index 00000000..2940a76f --- /dev/null +++ b/tests/olingo_server/src/main/webapp/index.jsp @@ -0,0 +1,56 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + + + + Apache Olingo - OData 4.0 + + + + +

+

Olingo OData 4.0

+
+

Cars Sample Service

+
+
+
+ <% String version = "gen/version.html"; + try { + %> + + <%} catch (Exception e) { + %> +

IDE Build

+ <%}%> +
+ + From 7fba3588286daf2c717208d1926787e736ff96a4 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 28 Feb 2020 13:38:49 +0100 Subject: [PATCH 24/36] Add type annotations to the majority of variables and functions in Service V2 Before developing the Service V4 I wanted to fully understand how V2 works, however without data types it was nearly impossible to grasp the meaning of the code. Hence, this commit. --- CHANGELOG.md | 1 + pyodata/exceptions.py | 7 +- pyodata/v2/service.py | 367 ++++++++++++++++++++++-------------------- 3 files changed, 200 insertions(+), 175 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c53bde3..e67254d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Permissive parsing for TypeDefinition - Changes all manually raised exception to be child of PyODataException - Martin Miksik - More comprehensive tests for ODATA V4 - Martin Miksik +- Majority of variables and functions in Service V2 are now type annotated - Martin Miksik ### Changed - Implementation and naming schema of `from_etree` - Martin Miksik diff --git a/pyodata/exceptions.py b/pyodata/exceptions.py index cf69e220..5bd06d35 100644 --- a/pyodata/exceptions.py +++ b/pyodata/exceptions.py @@ -1,4 +1,5 @@ """PyOData exceptions hierarchy""" +import requests class PyODataException(Exception): @@ -25,13 +26,13 @@ class HttpError(PyODataException): VendorType = None - def __new__(cls, message, response): + def __new__(cls, message: str, response: requests.Response) -> 'HttpError': if HttpError.VendorType is not None: return super(HttpError, cls).__new__(HttpError.VendorType, message, response) - return super(HttpError, cls).__new__(cls, message, response) + return super(HttpError, cls).__new__(cls, message, response) # type: ignore - def __init__(self, message, response): + def __init__(self, message: str, response: requests.Response) -> None: super(HttpError, self).__init__(message) self.response = response diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index c0af4c75..bb96f5f2 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -13,26 +13,28 @@ from email.parser import Parser from http.client import HTTPResponse from io import BytesIO - +from typing import List, Any, Optional, Tuple, Union, Dict, Callable import requests +from pyodata.model.elements import EntityType, StructTypeProperty, EntitySet, VariableDeclaration, FunctionImport from pyodata.model import elements from pyodata.v2 import elements as elements_v2 from pyodata.exceptions import HttpError, PyODataException, ExpressionError, PyODataModelError LOGGER_NAME = 'pyodata.service' +JSON_OBEJCT = Any -def urljoin(*path): +def urljoin(*path: str) -> str: """Joins the passed string parts into a one string url""" return '/'.join((part.strip('/') for part in path)) -def encode_multipart(boundary, http_requests): +def encode_multipart(boundary: str, http_requests: List['ODataHttpRequest']) -> str: """Encode list of requests into multipart body""" - lines = [] + lines: List[str] = [] lines.append('') @@ -55,14 +57,16 @@ def encode_multipart(boundary, http_requests): lines.append(line) # request specific headers - for hdr, hdr_val in req.get_headers().items(): - lines.append('{}: {}'.format(hdr, hdr_val)) + headers = req.get_headers() + if headers is not None: + for hdr, hdr_val in headers.items(): + lines.append('{}: {}'.format(hdr, hdr_val)) lines.append('') body = req.get_body() if body is not None: - lines.append(req.get_body()) + lines.append(body) else: # this is very important since SAP gateway rejected request witout this line. It seems # blank line must be provided as a representation of emtpy body, else we are getting @@ -74,10 +78,11 @@ def encode_multipart(boundary, http_requests): return '\r\n'.join(lines) -def decode_multipart(data, content_type): +# Todo remove any +def decode_multipart(data: str, content_type: str) -> Any: """Decode parts of the multipart mime content""" - def decode(message): + def decode(message: Any) -> Any: """Decode tree of messages for specific message""" messages = [] @@ -100,13 +105,13 @@ def decode(message): class ODataHttpResponse: """Representation of http response""" - def __init__(self, headers, status_code, content=None): + def __init__(self, headers: List[Tuple[str, str]], status_code: int, content: Optional[bytes] = None): self.headers = headers self.status_code = status_code self.content = content @staticmethod - def from_string(data): + def from_string(data: str) -> 'ODataHttpResponse': """Parse http response to status code, headers and body Based on: https://stackoverflow.com/questions/24728088/python-parse-http-response-string @@ -115,17 +120,17 @@ def from_string(data): class FakeSocket: """Fake socket to simulate received http response content""" - def __init__(self, response_str): + def __init__(self, response_str: str): self._file = BytesIO(response_str.encode('utf-8')) - def makefile(self, *args, **kwargs): + def makefile(self, *args: Any, **kwargs: Any) -> Any: """Fake file that provides string content""" # pylint: disable=unused-argument return self._file source = FakeSocket(data) - response = HTTPResponse(source) + response = HTTPResponse(source) # type: ignore response.begin() return ODataHttpResponse( @@ -134,9 +139,8 @@ def makefile(self, *args, **kwargs): response.read(len(data)) # the len here will give a 'big enough' value to read the whole content ) - def json(self): + def json(self) -> Optional[JSON_OBEJCT]: """Return response as decoded json""" - # TODO: see implementation in python requests, our simple # approach can bring issues with encoding # https://github.com/requests/requests/blob/master/requests/models.py#L868 @@ -158,15 +162,15 @@ class EntityKey: Entity-keys are equal if their string representations are equal. """ - TYPE_SINGLE = 0 - TYPE_COMPLEX = 1 + TYPE_SINGLE: int = 0 + TYPE_COMPLEX: int = 1 - def __init__(self, entity_type, single_key=None, **args): + def __init__(self, entity_type: EntityType, single_key: Optional[Union[int, str]] = None, **args: Union[str, int]): self._logger = logging.getLogger(LOGGER_NAME) self._proprties = args - self._entity_type = entity_type - self._key = entity_type.key_proprties + self._entity_type: EntityType = entity_type + self._key: List[StructTypeProperty] = entity_type.key_proprties # single key does not need property name if single_key is not None: @@ -193,18 +197,18 @@ def __init__(self, entity_type, single_key=None, **args): self._type = EntityKey.TYPE_COMPLEX @property - def key_properties(self): + def key_properties(self) -> List[StructTypeProperty]: """Key properties""" return self._key - def to_key_string_without_parentheses(self): + def to_key_string_without_parentheses(self) -> str: """Gets the string representation of the key without parentheses""" if self._type == EntityKey.TYPE_SINGLE: # first property is the key property key_prop = self._key[0] - return key_prop.typ.traits.to_literal(self._proprties[key_prop.name]) + return key_prop.typ.traits.to_literal(self._proprties[key_prop.name]) # type: ignore key_pairs = [] for key_prop in self._key: @@ -216,19 +220,20 @@ def to_key_string_without_parentheses(self): return ','.join(key_pairs) - def to_key_string(self): + def to_key_string(self) -> str: """Gets the string representation of the key, including parentheses""" return '({})'.format(self.to_key_string_without_parentheses()) - def __repr__(self): + def __repr__(self) -> str: return self.to_key_string() class ODataHttpRequest: """Deferred HTTP Request""" - def __init__(self, url, connection, handler, headers=None): + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + headers: Optional[Dict[str, str]] = None): self._connection = connection self._url = url self._handler = handler @@ -236,36 +241,36 @@ def __init__(self, url, connection, handler, headers=None): self._logger = logging.getLogger(LOGGER_NAME) @property - def handler(self): + def handler(self) -> Callable[[requests.Response], Any]: """Getter for handler""" return self._handler - def get_path(self): + def get_path(self) -> str: """Get path of the HTTP request""" # pylint: disable=no-self-use return '' - def get_query_params(self): + def get_query_params(self) -> Dict[Any, Any]: """Get query params""" # pylint: disable=no-self-use return {} - def get_method(self): + def get_method(self) -> str: """Get HTTP method""" # pylint: disable=no-self-use return 'GET' - def get_body(self): + def get_body(self) -> Optional[str]: """Get HTTP body or None if not applicable""" # pylint: disable=no-self-use return None - def get_headers(self): + def get_headers(self) -> Optional[Dict[str, str]]: """Get dict of HTTP headers""" # pylint: disable=no-self-use return None - def execute(self): + def execute(self) -> Any: """Fetches HTTP response and returns processed result Sends the query-request to the OData service, returning a client-side Enumerable for @@ -309,22 +314,23 @@ def execute(self): class EntityGetRequest(ODataHttpRequest): """Used for GET operations of a single entity""" - def __init__(self, handler, entity_key, entity_set_proxy): + def __init__(self, handler: Callable[[requests.Response], Any], entity_key: EntityKey, + entity_set_proxy: 'EntitySetProxy'): super(EntityGetRequest, self).__init__(entity_set_proxy.service.url, entity_set_proxy.service.connection, handler) self._logger = logging.getLogger(LOGGER_NAME) self._entity_key = entity_key self._entity_set_proxy = entity_set_proxy - self._select = None - self._expand = None + self._select: Optional[str] = None + self._expand: Optional[str] = None self._logger.debug('New instance of EntityGetRequest for last segment: %s', self._entity_set_proxy.last_segment) - def nav(self, nav_property): + def nav(self, nav_property: str) -> 'EntitySetProxy': """Navigates to given navigation property and returns the EntitySetProxy""" return self._entity_set_proxy.nav(nav_property, self._entity_key) - def select(self, select): + def select(self, select: str) -> 'EntityGetRequest': """Specifies a subset of properties to return. @param select a comma-separated list of selection clauses @@ -332,7 +338,7 @@ def select(self, select): self._select = select return self - def expand(self, expand): + def expand(self, expand: str) -> 'EntityGetRequest': """Specifies related entities to expand inline as part of the response. @param expand a comma-separated list of navigation properties @@ -340,13 +346,13 @@ def expand(self, expand): self._expand = expand return self - def get_path(self): - return self._entity_set_proxy.last_segment + self._entity_key.to_key_string() + def get_path(self) -> str: + return str(self._entity_set_proxy.last_segment + self._entity_key.to_key_string()) - def get_headers(self): + def get_headers(self) -> Dict[str, str]: return {'Accept': 'application/json'} - def get_query_params(self): + def get_query_params(self) -> Dict[str, str]: qparams = super(EntityGetRequest, self).get_query_params() if self._select is not None: @@ -357,13 +363,13 @@ def get_query_params(self): return qparams - def get_value(self, connection=None): + def get_value(self, connection: Optional[requests.Session] = None) -> ODataHttpRequest: """Returns Value of Media EntityTypes also known as the $value URL suffix.""" if connection is None: connection = self._connection - def stream_handler(response): + def stream_handler(response: requests.Response) -> requests.Response: """Returns $value from HTTP Response""" if response.status_code != requests.codes.ok: @@ -381,12 +387,14 @@ def stream_handler(response): class NavEntityGetRequest(EntityGetRequest): """Used for GET operations of a single entity accessed via a Navigation property""" - def __init__(self, handler, master_key, entity_set_proxy, nav_property): + def __init__(self, handler: Callable[[requests.Response], Any], master_key: EntityKey, + entity_set_proxy: 'EntitySetProxy', + nav_property: str): super(NavEntityGetRequest, self).__init__(handler, master_key, entity_set_proxy) self._nav_property = nav_property - def get_path(self): + def get_path(self) -> str: return "{}/{}".format(super(NavEntityGetRequest, self).get_path(), self._nav_property) @@ -396,18 +404,20 @@ class EntityCreateRequest(ODataHttpRequest): Call execute() to send the create-request to the OData service and get the newly created entity.""" - def __init__(self, url, connection, handler, entity_set, last_segment=None): + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + entity_set: EntitySet, + last_segment: Optional[str] = None): super(EntityCreateRequest, self).__init__(url, connection, handler) self._logger = logging.getLogger(LOGGER_NAME) self._entity_set = entity_set self._entity_type = entity_set.entity_type if last_segment is None: - self._last_segment = self._entity_set.name + self._last_segment: str = self._entity_set.name else: self._last_segment = last_segment - self._values = {} + self._values: Dict[str, str] = {} # get all properties declared by entity type self._type_props = self._entity_type.proprties() @@ -415,14 +425,14 @@ def __init__(self, url, connection, handler, entity_set, last_segment=None): self._logger.debug('New instance of EntityCreateRequest for entity type: %s on path %s', self._entity_type.name, self._last_segment) - def get_path(self): + def get_path(self) -> str: return self._last_segment - def get_method(self): + def get_method(self) -> str: # pylint: disable=no-self-use return 'POST' - def _get_body(self): + def _get_body(self) -> Any: """Recursively builds a dictionary of values where some of the values might be another entities. """ @@ -437,14 +447,14 @@ def _get_body(self): return body - def get_body(self): + def get_body(self) -> str: return json.dumps(self._get_body()) - def get_headers(self): + def get_headers(self) -> Dict[str, str]: return {'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'X'} @staticmethod - def _build_values(entity_type, entity): + def _build_values(entity_type: EntityType, entity: Any) -> Any: """Recursively converts a dictionary of values where some of the values might be another entities (navigation properties) into the internal representation. @@ -456,10 +466,10 @@ def _build_values(entity_type, entity): values = {} for key, val in entity.items(): try: - val = entity_type.proprty(key).typ.traits.to_json(val) + val = entity_type.proprty(key).typ.traits.to_json(val) # type: ignore except PyODataModelError: try: - nav_prop = entity_type.nav_proprty(key) + nav_prop = entity_type.nav_proprty(key) # type: ignore val = EntityCreateRequest._build_values(nav_prop.typ, val) except PyODataModelError: raise PyODataException('Property {} is not declared in {} entity type'.format( @@ -469,7 +479,7 @@ def _build_values(entity_type, entity): return values - def set(self, **kwargs): + def set(self, **kwargs: Any) -> 'EntityCreateRequest': """Set properties on the new entity.""" self._logger.info(kwargs) @@ -483,7 +493,9 @@ def set(self, **kwargs): class EntityDeleteRequest(ODataHttpRequest): """Used for deleting entity (DELETE operations on a single entity)""" - def __init__(self, url, connection, handler, entity_set, entity_key): + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + entity_set: EntitySet, + entity_key: EntityKey): super(EntityDeleteRequest, self).__init__(url, connection, handler) self._logger = logging.getLogger(LOGGER_NAME) self._entity_set = entity_set @@ -491,10 +503,10 @@ def __init__(self, url, connection, handler, entity_set, entity_key): self._logger.debug('New instance of EntityDeleteRequest for entity type: %s', entity_set.entity_type.name) - def get_path(self): - return self._entity_set.name + self._entity_key.to_key_string() + def get_path(self) -> str: + return str(self._entity_set.name + self._entity_key.to_key_string()) - def get_method(self): + def get_method(self) -> str: # pylint: disable=no-self-use return 'DELETE' @@ -505,38 +517,39 @@ class EntityModifyRequest(ODataHttpRequest): Call execute() to send the update-request to the OData service and get the modified entity.""" - def __init__(self, url, connection, handler, entity_set, entity_key): + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + entity_set: EntitySet, entity_key: EntityKey): super(EntityModifyRequest, self).__init__(url, connection, handler) self._logger = logging.getLogger(LOGGER_NAME) self._entity_set = entity_set self._entity_type = entity_set.entity_type self._entity_key = entity_key - self._values = {} + self._values: Dict[str, str] = {} # get all properties declared by entity type self._type_props = self._entity_type.proprties() self._logger.debug('New instance of EntityModifyRequest for entity type: %s', self._entity_type.name) - def get_path(self): - return self._entity_set.name + self._entity_key.to_key_string() + def get_path(self) -> str: + return str(self._entity_set.name + self._entity_key.to_key_string()) - def get_method(self): + def get_method(self) -> str: # pylint: disable=no-self-use return 'PATCH' - def get_body(self): + def get_body(self) -> str: # pylint: disable=no-self-use body = {} for key, val in self._values.items(): body[key] = val return json.dumps(body) - def get_headers(self): + def get_headers(self) -> Dict[str, str]: return {'Accept': 'application/json', 'Content-Type': 'application/json'} - def set(self, **kwargs): + def set(self, **kwargs: Any) -> 'EntityModifyRequest': """Set properties to be changed.""" self._logger.info(kwargs) @@ -558,38 +571,39 @@ class QueryRequest(ODataHttpRequest): # pylint: disable=too-many-instance-attributes - def __init__(self, url, connection, handler, last_segment): + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + last_segment: str): super(QueryRequest, self).__init__(url, connection, handler) self._logger = logging.getLogger(LOGGER_NAME) - self._count = None - self._top = None - self._skip = None - self._order_by = None - self._filter = None - self._select = None - self._expand = None + self._count: Optional[bool] = None + self._top: Optional[int] = None + self._skip: Optional[int] = None + self._order_by: Optional[str] = None + self._filter: Optional[str] = None + self._select: Optional[str] = None + self._expand: Optional[str] = None self._last_segment = last_segment - self._customs = {} # string -> string hash + self._customs: Dict[str, str] = {} # string -> string hash self._logger.debug('New instance of QueryRequest for last segment: %s', self._last_segment) - def custom(self, name, value): + def custom(self, name: str, value: str) -> 'QueryRequest': """Adds a custom name-value pair.""" # returns QueryRequest self._customs[name] = value return self - def count(self): + def count(self) -> 'QueryRequest': """Sets a flag to return the number of items.""" self._count = True return self - def expand(self, expand): + def expand(self, expand: str) -> 'QueryRequest': """Sets the expand expressions.""" self._expand = expand return self - def filter(self, filter_val): + def filter(self, filter_val: str) -> 'QueryRequest': """Sets the filter expression.""" # returns QueryRequest self._filter = filter_val @@ -600,33 +614,33 @@ def filter(self, filter_val): # # returns QueryRequest # raise NotImplementedError - def order_by(self, order_by): + def order_by(self, order_by: str) -> 'QueryRequest': """Sets the ordering expressions.""" self._order_by = order_by return self - def select(self, select): + def select(self, select: str) -> 'QueryRequest': """Sets the selection clauses.""" self._select = select return self - def skip(self, skip): + def skip(self, skip: int) -> 'QueryRequest': """Sets the number of items to skip.""" self._skip = skip return self - def top(self, top): + def top(self, top: int) -> 'QueryRequest': """Sets the number of items to return.""" self._top = top return self - def get_path(self): + def get_path(self) -> str: if self._count: return urljoin(self._last_segment, '/$count') return self._last_segment - def get_headers(self): + def get_headers(self) -> Dict[str, str]: if self._count: return {} @@ -634,7 +648,7 @@ def get_headers(self): 'Accept': 'application/json', } - def get_query_params(self): + def get_query_params(self) -> Dict[str, str]: qparams = super(QueryRequest, self).get_query_params() if self._top is not None: @@ -664,19 +678,20 @@ def get_query_params(self): class FunctionRequest(QueryRequest): """Function import request (Service call)""" - def __init__(self, url, connection, handler, function_import): + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + function_import: FunctionImport): super(FunctionRequest, self).__init__(url, connection, handler, function_import.name) self._function_import = function_import self._logger.debug('New instance of FunctionRequest for %s', self._function_import.name) - def parameter(self, name, value): + def parameter(self, name: str, value: int) -> 'FunctionRequest': '''Sets value of parameter.''' # check if param is valid (is declared in metadata) try: - param = self._function_import.get_parameter(name) + param = self._function_import.get_parameter(name) # type: ignore # add parameter as custom query argument self.custom(param.name, param.typ.traits.to_literal(value)) @@ -686,10 +701,10 @@ def parameter(self, name, value): return self - def get_method(self): - return self._function_import.http_method + def get_method(self) -> str: + return self._function_import.http_method # type: ignore - def get_headers(self): + def get_headers(self) -> Dict[str, str]: return { 'Accept': 'application/json', } @@ -703,13 +718,14 @@ class EntityProxy: # pylint: disable=too-many-branches,too-many-nested-blocks - def __init__(self, service, entity_set, entity_type, proprties=None, entity_key=None): + def __init__(self, service: 'Service', entity_set: Union[EntitySet, 'EntitySetProxy', None], + entity_type: EntityType, proprties: Optional[Any] = None, entity_key: Optional[EntityKey] = None): self._logger = logging.getLogger(LOGGER_NAME) self._service = service self._entity_set = entity_set self._entity_type = entity_type self._key_props = entity_type.key_proprties - self._cache = dict() + self._cache: Dict[str, Any] = dict() self._entity_key = entity_key self._logger.debug('New entity proxy instance of type %s from properties: %s', entity_type.name, proprties) @@ -718,7 +734,7 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key= if proprties is not None: # first, cache values of direct properties - for type_proprty in self._entity_type.proprties(): + for type_proprty in self._entity_type.proprties(): # type: ignore if type_proprty.name in proprties: if proprties[type_proprty.name] is not None: self._cache[type_proprty.name] = type_proprty.typ.traits.from_json(proprties[type_proprty.name]) @@ -776,10 +792,10 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key= except PyODataException: pass - def __repr__(self): + def __repr__(self) -> str: return self._entity_key.to_key_string() - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> Any: try: return self._cache[attr] except KeyError: @@ -791,26 +807,27 @@ def __getattr__(self, attr): raise AttributeError('EntityType {0} does not have Property {1}: {2}' .format(self._entity_type.name, attr, str(ex))) - def nav(self, nav_property): + def nav(self, nav_property: str) -> Union['NavEntityProxy', 'EntitySetProxy']: """Navigates to given navigation property and returns the EntitySetProxy""" # for now duplicated with simillar method in entity set proxy class try: - navigation_property = self._entity_type.nav_proprty(nav_property) + navigation_property = self._entity_type.nav_proprty(nav_property) # type: ignore except KeyError: raise PyODataException('Navigation property {} is not declared in {} entity type'.format( nav_property, self._entity_type)) # Get entity set of navigation property association_info = navigation_property.association_info - association_set = self._service.schema.association_set_by_association( + association_set = self._service.schema.association_set_by_association( # type: ignore association_info.name, association_info.namespace) navigation_entity_set = None for end in association_set.end_roles: if association_set.end_by_entity_set(end.entity_set_name).role == navigation_property.to_role.role: - navigation_entity_set = self._service.schema.entity_set(end.entity_set_name, association_info.namespace) + navigation_entity_set = self._service.schema.entity_set(end.entity_set_name, + association_info.namespace) # type: ignore if not navigation_entity_set: raise PyODataException('No association set for role {}'.format(navigation_property.to_role)) @@ -821,21 +838,21 @@ def nav(self, nav_property): return EntitySetProxy( self._service, - self._service.schema.entity_set(navigation_entity_set.name), + self._service.schema.entity_set(navigation_entity_set.name), # type: ignore nav_property, - self._entity_set.name + self._entity_key.to_key_string()) + self._entity_set.name + self._entity_key.to_key_string()) # type: ignore - def get_path(self): + def get_path(self) -> str: """Returns this entity's relative path - e.g. EntitySet(KEY)""" return self._entity_set._name + self._entity_key.to_key_string() # pylint: disable=protected-access - def get_proprty(self, name, connection=None): + def get_proprty(self, name: str, connection: Optional[requests.Session] = None) -> ODataHttpRequest: """Returns value of the property""" self._logger.info('Initiating property request for %s', name) - def proprty_get_handler(key, proprty, response): + def proprty_get_handler(key: str, proprty: VariableDeclaration, response: requests.Response) -> Any: """Gets property value from HTTP Response""" if response.status_code != requests.codes.ok: @@ -851,10 +868,10 @@ def proprty_get_handler(key, proprty, response): partial(proprty_get_handler, path, self._entity_type.proprty(name)), connection=connection) - def get_value(self, connection=None): + def get_value(self, connection: Optional[requests.Session] = None) -> ODataHttpRequest: "Returns $value of Stream entities" - def value_get_handler(key, response): + def value_get_handler(key: Any, response: requests.Response) -> requests.Response: """Gets property value from HTTP Response""" if response.status_code != requests.codes.ok: @@ -869,19 +886,19 @@ def value_get_handler(key, response): connection=connection) @property - def entity_set(self): + def entity_set(self) -> EntitySet: """Entity set related to this entity""" return self._entity_set @property - def entity_key(self): + def entity_key(self) -> Optional[EntityKey]: """Key of entity""" return self._entity_key @property - def url(self): + def url(self) -> str: """URL of the real entity""" service_url = self._service.url.rstrip('/') @@ -889,7 +906,7 @@ def url(self): return urljoin(service_url, entity_path) - def equals(self, other): + def equals(self, other) -> bool: """Returns true if the self and the other contains the same data""" # pylint: disable=W0212 return self._cache == other._cache @@ -898,14 +915,14 @@ def equals(self, other): class NavEntityProxy(EntityProxy): """Special case of an Entity access via 1 to 1 Navigation property""" - def __init__(self, parent_entity, prop_name, entity_type, entity): + def __init__(self, parent_entity: EntityProxy, prop_name: str, entity_type: EntityType, entity: Dict[str, str]): # pylint: disable=protected-access super(NavEntityProxy, self).__init__(parent_entity._service, parent_entity._entity_set, entity_type, entity) self._parent_entity = parent_entity self._prop_name = prop_name - def get_path(self): + def get_path(self) -> str: """Returns URL of the entity""" return urljoin(self._parent_entity.get_path(), self._prop_name) @@ -914,11 +931,11 @@ def get_path(self): class GetEntitySetFilter: """Create filters for humans""" - def __init__(self, proprty): + def __init__(self, proprty: StructTypeProperty): self._proprty = proprty @staticmethod - def build_expression(operator, operands): + def build_expression(operator: str, operands: Tuple[str, ...]) -> str: """Creates a expression by joining the operands with the operator""" if len(operands) < 2: @@ -927,39 +944,40 @@ def build_expression(operator, operands): return '({})'.format(' {} '.format(operator).join(operands)) @staticmethod - def and_(*operands): + def and_(*operands: str) -> str: """Creates logical AND expression from the operands""" return GetEntitySetFilter.build_expression('and', operands) @staticmethod - def or_(*operands): + def or_(*operands: str) -> str: """Creates logical OR expression from the operands""" return GetEntitySetFilter.build_expression('or', operands) @staticmethod - def format_filter(proprty, operator, value): + def format_filter(proprty: StructTypeProperty, operator: str, value: str) -> str: """Creates a filter expression """ return '{} {} {}'.format(proprty.name, operator, proprty.typ.traits.to_literal(value)) - def __eq__(self, value): + def __eq__(self, value: str) -> str: # type: ignore return GetEntitySetFilter.format_filter(self._proprty, 'eq', value) - def __ne__(self, value): + def __ne__(self, value: str) -> str: # type: ignore return GetEntitySetFilter.format_filter(self._proprty, 'ne', value) class GetEntitySetRequest(QueryRequest): """GET on EntitySet""" - def __init__(self, url, connection, handler, last_segment, entity_type): + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + last_segment: str, entity_type: EntityType): super(GetEntitySetRequest, self).__init__(url, connection, handler, last_segment) self._entity_type = entity_type - def __getattr__(self, name): + def __getattr__(self, name: str) -> GetEntitySetFilter: proprty = self._entity_type.proprty(name) return GetEntitySetFilter(proprty) @@ -967,7 +985,8 @@ def __getattr__(self, name): class EntitySetProxy: """EntitySet Proxy""" - def __init__(self, service, entity_set, alias=None, parent_last_segment=None): + def __init__(self, service: 'Service', entity_set: EntitySet, alias: Optional[str] = None, + parent_last_segment: Optional[str] = None): """Creates new Entity Set object @param alias in case the entity set is access via assossiation @@ -990,18 +1009,18 @@ def __init__(self, service, entity_set, alias=None, parent_last_segment=None): self._logger.debug('New entity set proxy instance for %s', self._name) @property - def service(self): + def service(self) -> 'Service': """Return service""" return self._service @property - def last_segment(self): + def last_segment(self) -> str: """Return last segment of url""" - entity_set_name = self._alias if self._alias is not None else self._entity_set.name + entity_set_name: str = self._alias if self._alias is not None else self._entity_set.name return self._parent_last_segment + entity_set_name - def nav(self, nav_property, key): + def nav(self, nav_property: str, key: EntityKey) -> 'EntitySetProxy': """Navigates to given navigation property and returns the EntitySetProxy""" try: @@ -1034,10 +1053,11 @@ def nav(self, nav_property, key): nav_property, self._entity_set.name + key.to_key_string()) - def _get_nav_entity(self, master_key, nav_property, navigation_entity_set): + def _get_nav_entity(self, master_key: EntityKey, nav_property: str, + navigation_entity_set: EntitySet) -> NavEntityGetRequest: """Get entity based on provided key of the master and Navigation property name""" - def get_entity_handler(parent, nav_property, navigation_entity_set, response): + def get_entity_handler(parent, nav_property, navigation_entity_set, response) -> NavEntityProxy: """Gets entity from HTTP response""" if response.status_code != requests.codes.ok: @@ -1150,10 +1170,10 @@ def update_entity_handler(response): return EntityModifyRequest(self._service.url, self._service.connection, update_entity_handler, self._entity_set, entity_key) - def delete_entity(self, key: EntityKey = None, **kwargs): + def delete_entity(self, key: Optional[EntityKey] = None, **kwargs: Any) -> EntityDeleteRequest: """Delete the entity""" - def delete_entity_handler(response): + def delete_entity_handler(response: requests.Response) -> None: """Check if entity deletion was successful""" if response.status_code != 204: @@ -1174,15 +1194,15 @@ def delete_entity_handler(response): class EntityContainer: """Set of EntitSet proxies""" - def __init__(self, service): + def __init__(self, service: 'Service'): self._service = service - self._entity_sets = dict() + self._entity_sets: Dict[str, EntitySetProxy] = dict() for entity_set in self._service.schema.entity_sets: self._entity_sets[entity_set.name] = EntitySetProxy(self._service, entity_set) - def __getattr__(self, name): + def __getattr__(self, name: str) -> EntitySetProxy: try: return self._entity_sets[name] except KeyError: @@ -1196,23 +1216,24 @@ class FunctionContainer: Call a server-side functions (also known as a service operation). """ - def __init__(self, service): + def __init__(self, service: 'Service'): self._service = service - self._functions = dict() + self._functions: Dict[str, FunctionImport] = dict() for fimport in self._service.schema.function_imports: self._functions[fimport.name] = fimport - def __getattr__(self, name): + def __getattr__(self, name: str) -> FunctionRequest: if name not in self._functions: raise AttributeError( 'Function {0} not defined in {1}.'.format(name, ','.join(list(self._functions.keys())))) - fimport = self._service.schema.function_import(name) + fimport = self._service.schema.function_import(name) # type: ignore - def function_import_handler(fimport, response): + def function_import_handler(fimport: FunctionImport, + response: requests.Response) -> Union[EntityProxy, None, Any]: """Get function call response from HTTP Response""" if 300 <= response.status_code < 400: @@ -1262,7 +1283,7 @@ def function_import_handler(fimport, response): # 1. if return types is "entity type", return instance of appropriate entity proxy if isinstance(fimport.return_type, elements.EntityType): - entity_set = self._service.schema.entity_set(fimport.entity_set_name) + entity_set = self._service.schema.entity_set(fimport.entity_set_name) # type: ignore return EntityProxy(self._service, entity_set, fimport.return_type, response_data) # 2. return raw data for all other return types (primitives, complex types encoded in dicts, etc.) @@ -1275,7 +1296,7 @@ def function_import_handler(fimport, response): class Service: """OData service""" - def __init__(self, url, schema, connection): + def __init__(self, url: str, schema: elements_v2.Schema, connection: requests.Session): self._url = url self._schema = schema self._connection = connection @@ -1283,36 +1304,36 @@ def __init__(self, url, schema, connection): self._function_container = FunctionContainer(self) @property - def schema(self): + def schema(self) -> elements_v2.Schema: """Parsed metadata""" return self._schema @property - def url(self): + def url(self) -> str: """Service url""" return self._url @property - def connection(self): + def connection(self) -> requests.Session: """Service connection""" return self._connection @property - def entity_sets(self): + def entity_sets(self) -> EntityContainer: """EntitySet proxy""" return self._entity_container @property - def functions(self): + def functions(self) -> FunctionContainer: """Functions proxy""" return self._function_container - def http_get(self, path, connection=None): + def http_get(self, path: str, connection: Optional[requests.Session] = None) -> requests.Response: """HTTP GET response for the passed path in the service""" conn = connection @@ -1321,7 +1342,8 @@ def http_get(self, path, connection=None): return conn.get(urljoin(self._url, path)) - def http_get_odata(self, path, handler, connection=None): + def http_get_odata(self, path: str, handler: Callable[[requests.Response], Any], + connection: Optional[requests.Session] = None): """HTTP GET request proxy for the passed path in the service""" conn = connection @@ -1334,10 +1356,10 @@ def http_get_odata(self, path, handler, connection=None): handler, headers={'Accept': 'application/json'}) - def create_batch(self, batch_id=None): + def create_batch(self, batch_id: Optional[str] = None) -> 'BatchRequest': """Create instance of OData batch request""" - def batch_handler(batch, parts): + def batch_handler(batch: MultipartRequest, parts: List[List[str]]) -> List[Any]: """Process parsed multipart request (parts)""" logging.getLogger(LOGGER_NAME).debug('Batch handler called for batch %s', batch.id) @@ -1362,12 +1384,12 @@ def batch_handler(batch, parts): def create_changeset(self, changeset_id=None): """Create instance of OData changeset""" - def changeset_handler(changeset, parts): + def changeset_handler(changeset: 'Changeset', parts: List[str]): """Gets changeset response from HTTP response""" logging.getLogger(LOGGER_NAME).debug('Changeset handler called for changeset %s', changeset.id) - result = [] + result: List[ODataHttpResponse] = [] # check if changeset response consists of parts, this is important # to distinguish cases when server responds with single HTTP response @@ -1400,10 +1422,11 @@ def changeset_handler(changeset, parts): class MultipartRequest(ODataHttpRequest): """HTTP Batch request""" - def __init__(self, url, connection, handler, request_id=None): + def __init__(self, url: str, connection: requests.Session, handler: Callable[[ODataHttpResponse], Any], + request_id: Optional[str] = None): super(MultipartRequest, self).__init__(url, connection, partial(MultipartRequest.http_response_handler, self)) - self.requests = [] + self.requests: List[ODataHttpRequest] = [] self._handler_decoded = handler # generate random id of form dddd-dddd-dddd @@ -1414,28 +1437,28 @@ def __init__(self, url, connection, handler, request_id=None): self._logger.debug('New multipart %s request initialized, id=%s', self.__class__.__name__, self.id) @property - def handler(self): + def handler(self) -> Callable[['ODataHttpResponse'], Any]: return self._handler_decoded - def get_boundary(self): + def get_boundary(self) -> str: """Get boundary used for request parts""" return self.id - def get_headers(self): + def get_headers(self) -> Dict[str, str]: # pylint: disable=no-self-use return {'Content-Type': 'multipart/mixed;boundary={}'.format(self.get_boundary())} - def get_body(self): + def get_body(self) -> str: return encode_multipart(self.get_boundary(), self.requests) - def add_request(self, request): + def add_request(self, request: ODataHttpRequest) -> None: """Add request to be sent in batch""" self.requests.append(request) self._logger.debug('New %s request added to multipart request %s', request.get_method(), self.id) @staticmethod - def http_response_handler(request, response): + def http_response_handler(request: 'MultipartRequest', response: requests.Response) -> Any: """Process HTTP response to mutipart HTTP request""" if response.status_code != 202: # 202 Accepted @@ -1453,14 +1476,14 @@ def http_response_handler(request, response): class BatchRequest(MultipartRequest): """HTTP Batch request""" - def get_boundary(self): - return 'batch_' + self.id + def get_boundary(self) -> str: + return str('batch_' + self.id) - def get_path(self): + def get_path(self) -> str: # pylint: disable=no-self-use return '$batch' - def get_method(self): + def get_method(self) -> str: # pylint: disable=no-self-use return 'POST' @@ -1468,5 +1491,5 @@ def get_method(self): class Changeset(MultipartRequest): """Representation of changeset (unsorted group of requests)""" - def get_boundary(self): + def get_boundary(self) -> str: return 'changeset_' + self.id From 06320bf7b88b48b40a78f8e50389a9e1f3857362 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 10 Apr 2020 12:47:00 +0200 Subject: [PATCH 25/36] V4 service test implementation --- Makefile | 2 +- pyodata/client.py | 10 +- pyodata/model/elements.py | 6 + pyodata/v2/service.py | 10 +- pyodata/v4/__init__.py | 1 + pyodata/v4/build_functions.py | 5 +- pyodata/v4/service.py | 1501 +++++++++++++++++ pyodata/version.py | 4 + tests/olingo_server/Dockerfile | 12 - tests/olingo_server/pom.xml | 60 - .../olingo/server/sample/CarsServlet.java | 67 - .../server/sample/data/DataProvider.java | 195 --- .../sample/edmprovider/CarsEdmProvider.java | 183 -- .../sample/processor/CarsProcessor.java | 374 ---- .../src/main/resources/META-INF/LICENSE | 331 ---- .../main/resources/simplelogger.properties | 20 - .../src/main/version/version.html | 37 - .../src/main/webapp/WEB-INF/web.xml | 42 - .../src/main/webapp/css/olingo.css | 91 - .../src/main/webapp/img/OlingoOrangeTM.png | Bin 113360 -> 0 bytes tests/olingo_server/src/main/webapp/index.jsp | 56 - tests/v2/test_service.py | 3 + tests/v4/test_service.py | 93 + 23 files changed, 1626 insertions(+), 1477 deletions(-) create mode 100644 pyodata/v4/service.py delete mode 100644 tests/olingo_server/Dockerfile delete mode 100644 tests/olingo_server/pom.xml delete mode 100644 tests/olingo_server/src/main/java/org/apache/olingo/server/sample/CarsServlet.java delete mode 100644 tests/olingo_server/src/main/java/org/apache/olingo/server/sample/data/DataProvider.java delete mode 100644 tests/olingo_server/src/main/java/org/apache/olingo/server/sample/edmprovider/CarsEdmProvider.java delete mode 100644 tests/olingo_server/src/main/java/org/apache/olingo/server/sample/processor/CarsProcessor.java delete mode 100644 tests/olingo_server/src/main/resources/META-INF/LICENSE delete mode 100644 tests/olingo_server/src/main/resources/simplelogger.properties delete mode 100644 tests/olingo_server/src/main/version/version.html delete mode 100644 tests/olingo_server/src/main/webapp/WEB-INF/web.xml delete mode 100644 tests/olingo_server/src/main/webapp/css/olingo.css delete mode 100644 tests/olingo_server/src/main/webapp/img/OlingoOrangeTM.png delete mode 100644 tests/olingo_server/src/main/webapp/index.jsp create mode 100644 tests/v4/test_service.py diff --git a/Makefile b/Makefile index 58f69bc4..56f96462 100644 --- a/Makefile +++ b/Makefile @@ -70,7 +70,7 @@ build_olingo: $(DOCKER_BIN) build -t $(DOCKER_NAME) $(TESTS_OLINGO_SERVER) run_olingo: - $(DOCKER_BIN) run -it -p 8888:8080 --name $(DOCKER_NAME) $(DOCKER_NAME):latest + $(DOCKER_BIN) run -d -it -p 8888:8080 --name $(DOCKER_NAME) $(DOCKER_NAME):latest stop_olingo: $(DOCKER_BIN) stop $(DOCKER_NAME) diff --git a/pyodata/client.py b/pyodata/client.py index e1ce8d7d..1e9b02c8 100644 --- a/pyodata/client.py +++ b/pyodata/client.py @@ -8,6 +8,9 @@ from pyodata.exceptions import PyODataException, HttpError from pyodata.v2.service import Service from pyodata.v2 import ODataV2 +from pyodata.v4 import ODataV4 + +import pyodata.v4 as v4 def _fetch_metadata(connection, url, logger): @@ -62,11 +65,14 @@ def __new__(cls, url, connection, namespaces=None, config.namespaces = namespaces # create model instance from received metadata - logger.info('Creating OData Schema (version: %d)', config.odata_version) + logger.info('Creating OData Schema (version: %s)', str(config.odata_version)) schema = MetadataBuilder(metadata, config=config).build() # create service instance based on model we have - logger.info('Creating OData Service (version: %d)', config.odata_version) + logger.info('Creating OData Service (version: %s)', str(config.odata_version)) + service = Service(url, schema, connection) + if config.odata_version == ODataV4: + return v4.Service(url, schema, connection) return service diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 28186c68..64f30b17 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -273,6 +273,9 @@ def to_literal(self, value): return [self._item_type.traits.to_literal(v) for v in value] + def to_json(self, value): + return self.to_literal(value) + # pylint: disable=no-self-use def from_json(self, value): if not isinstance(value, list): @@ -280,6 +283,9 @@ def from_json(self, value): return [self._item_type.traits.from_json(v) for v in value] + def from_literal(self, value): + return self.from_json(value) + class VariableDeclaration(Identifier): MAXIMUM_LENGTH = -1 diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index bb96f5f2..50c4f327 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -469,7 +469,7 @@ def _build_values(entity_type: EntityType, entity: Any) -> Any: val = entity_type.proprty(key).typ.traits.to_json(val) # type: ignore except PyODataModelError: try: - nav_prop = entity_type.nav_proprty(key) # type: ignore + nav_prop = entity_type.nav_proprty(key) # type: ignore val = EntityCreateRequest._build_values(nav_prop.typ, val) except PyODataModelError: raise PyODataException('Property {} is not declared in {} entity type'.format( @@ -691,7 +691,7 @@ def parameter(self, name: str, value: int) -> 'FunctionRequest': # check if param is valid (is declared in metadata) try: - param = self._function_import.get_parameter(name) # type: ignore + param = self._function_import.get_parameter(name) # type: ignore # add parameter as custom query argument self.custom(param.name, param.typ.traits.to_literal(value)) @@ -734,7 +734,7 @@ def __init__(self, service: 'Service', entity_set: Union[EntitySet, 'EntitySetPr if proprties is not None: # first, cache values of direct properties - for type_proprty in self._entity_type.proprties(): # type: ignore + for type_proprty in self._entity_type.proprties(): # type: ignore if type_proprty.name in proprties: if proprties[type_proprty.name] is not None: self._cache[type_proprty.name] = type_proprty.typ.traits.from_json(proprties[type_proprty.name]) @@ -812,7 +812,7 @@ def nav(self, nav_property: str) -> Union['NavEntityProxy', 'EntitySetProxy']: # for now duplicated with simillar method in entity set proxy class try: - navigation_property = self._entity_type.nav_proprty(nav_property) # type: ignore + navigation_property = self._entity_type.nav_proprty(nav_property) # type: ignore except KeyError: raise PyODataException('Navigation property {} is not declared in {} entity type'.format( nav_property, self._entity_type)) @@ -1283,7 +1283,7 @@ def function_import_handler(fimport: FunctionImport, # 1. if return types is "entity type", return instance of appropriate entity proxy if isinstance(fimport.return_type, elements.EntityType): - entity_set = self._service.schema.entity_set(fimport.entity_set_name) # type: ignore + entity_set = self._service.schema.entity_set(fimport.entity_set_name) # type: ignore return EntityProxy(self._service, entity_set, fimport.return_type, response_data) # 2. return raw data for all other return types (primitives, complex types encoded in dicts, etc.) diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py index 1f464338..c68b42e0 100644 --- a/pyodata/v4/__init__.py +++ b/pyodata/v4/__init__.py @@ -13,6 +13,7 @@ build_navigation_type_property, build_navigation_property_binding, build_entity_set_with_v4_builder, build_enum_type from .type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \ EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration +from .service import Service # noqa class ODataV4(ODATAVersion): diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py index bd0ce8af..1e216355 100644 --- a/pyodata/v4/build_functions.py +++ b/pyodata/v4/build_functions.py @@ -92,7 +92,10 @@ def build_schema(config: Config, schema_nodes): for ref_con in nav_prop.referential_constraints: try: proprty = stype.proprty(ref_con.proprty_name) - referenced_proprty = nav_prop.typ.proprty(ref_con.referenced_proprty_name) + if nav_prop.typ.is_collection: + referenced_proprty = nav_prop.typ.item_type.proprty(ref_con.referenced_proprty_name) + else: + referenced_proprty = nav_prop.typ.proprty(ref_con.referenced_proprty_name) except PyODataModelError as ex: config.err_policy(ParserError.REFERENTIAL_CONSTRAINT).resolve(ex) proprty = NullProperty(ref_con.proprty_name) diff --git a/pyodata/v4/service.py b/pyodata/v4/service.py new file mode 100644 index 00000000..0a436ec0 --- /dev/null +++ b/pyodata/v4/service.py @@ -0,0 +1,1501 @@ +"""OData service implementation + + Details regarding batch requests and changesets: + http://www.odata.org/documentation/odata-version-2-0/batch-processing/ +""" + +# pylint: disable=too-many-lines + +import logging +from functools import partial +import json +import random +from email.parser import Parser +from http.client import HTTPResponse +from io import BytesIO +from typing import List, Any, Optional, Tuple, Union, Dict, Callable +import requests + +from pyodata.model.elements import EntityType, StructTypeProperty, EntitySet, VariableDeclaration, FunctionImport +from pyodata.model import elements +# from pyodata.v4 import elements as elements_v4 +from pyodata.exceptions import HttpError, PyODataException, ExpressionError, PyODataModelError + +LOGGER_NAME = 'pyodata.service' +JSON_OBJECT = Any + + +def urljoin(*path: str) -> str: + """Joins the passed string parts into a one string url""" + + return '/'.join((part.strip('/') for part in path)) + + +def encode_multipart(boundary: str, http_requests: List['ODataHttpRequest']) -> str: + """Encode list of requests into multipart body""" + + lines: List[str] = [] + + lines.append('') + + for req in http_requests: + + lines.append('--{0}'.format(boundary)) + + if not isinstance(req, MultipartRequest): + lines.extend(('Content-Type: application/http ', 'Content-Transfer-Encoding:binary')) + + lines.append('') + + # request line (method + path + query params) + line = '{method} {path}'.format(method=req.get_method(), path=req.get_path()) + query_params = '&'.join(['{}={}'.format(key, val) for key, val in req.get_query_params().items()]) + if query_params: + line += '?' + query_params + line += ' HTTP/1.1' + + lines.append(line) + + # request specific headers + headers = req.get_headers() + if headers is not None: + for hdr, hdr_val in headers.items(): + lines.append('{}: {}'.format(hdr, hdr_val)) + + lines.append('') + + body = req.get_body() + if body is not None: + lines.append(body) + else: + # this is very important since SAP gateway rejected request witout this line. It seems + # blank line must be provided as a representation of emtpy body, else we are getting + # 400 Bad fromat from SAP gateway + lines.append('') + + lines.append('--{0}--'.format(boundary)) + + return '\r\n'.join(lines) + + +# Todo remove any +def decode_multipart(data: str, content_type: str) -> Any: + """Decode parts of the multipart mime content""" + + def decode(message: Any) -> Any: + """Decode tree of messages for specific message""" + + messages = [] + for i, part in enumerate(message.walk()): # pylint: disable=unused-variable + if part.get_content_type() == 'multipart/mixed': + for submessage in part.get_payload(): + messages.append(decode(submessage)) + break + messages.append(part.get_payload()) + return messages + + data = "Content-Type: {}\n".format(content_type) + data + parser = Parser() + parsed = parser.parsestr(data) + decoded = decode(parsed) + + return decoded + + +class ODataHttpResponse: + """Representation of http response""" + + def __init__(self, headers: List[Tuple[str, str]], status_code: int, content: Optional[bytes] = None): + self.headers = headers + self.status_code = status_code + self.content = content + + @staticmethod + def from_string(data: str) -> 'ODataHttpResponse': + """Parse http response to status code, headers and body + + Based on: https://stackoverflow.com/questions/24728088/python-parse-http-response-string + """ + + class FakeSocket: + """Fake socket to simulate received http response content""" + + def __init__(self, response_str: str): + self._file = BytesIO(response_str.encode('utf-8')) + + def makefile(self, *args: Any, **kwargs: Any) -> Any: + """Fake file that provides string content""" + # pylint: disable=unused-argument + + return self._file + + source = FakeSocket(data) + response = HTTPResponse(source) # type: ignore + response.begin() + + return ODataHttpResponse( + response.getheaders(), + response.status, + response.read(len(data)) # the len here will give a 'big enough' value to read the whole content + ) + + def json(self) -> Optional[JSON_OBJECT]: + """Return response as decoded json""" + # TODO: see implementation in python requests, our simple + # approach can bring issues with encoding + # https://github.com/requests/requests/blob/master/requests/models.py#L868 + if self.content: + return json.loads(self.content.decode('utf-8')) + return None + + +class EntityKey: + """An immutable entity-key, made up of either a single value (single) + or multiple key-value pairs (complex). + + Every entity must have an entity-key. The entity-key must be unique + within the entity-set, and thus defines an entity's identity. + + The string representation of an entity-key is wrapped with parentheses, + such as (2), ('foo') or (a=1,foo='bar'). + + Entity-keys are equal if their string representations are equal. + """ + + TYPE_SINGLE: int = 0 + TYPE_COMPLEX: int = 1 + + def __init__(self, entity_type: EntityType, single_key: Optional[Union[int, str]] = None, **args: Union[str, int]): + + self._logger = logging.getLogger(LOGGER_NAME) + self._proprties = args + self._entity_type: EntityType = entity_type + self._key: List[StructTypeProperty] = entity_type.key_proprties + + # single key does not need property name + if single_key is not None: + + # check that entity type key consists of exactly one property + if len(self._key) != 1: + raise PyODataException(('Key of entity type {} consists of multiple properties {} ' + 'and cannot be initialized by single value').format( + self._entity_type.name, ', '.join([prop.name for prop in self._key]))) + + # get single key property and format key string + key_prop = self._key[0] + args[key_prop.name] = single_key + + self._type = EntityKey.TYPE_SINGLE + + self._logger.debug(('Detected single property key, adding pair %s->%s to key' + 'properties'), key_prop.name, single_key) + else: + for key_prop in self._key: + if key_prop.name not in args: + raise PyODataException('Missing value for key property {}'.format(key_prop.name)) + + self._type = EntityKey.TYPE_COMPLEX + + @property + def key_properties(self) -> List[StructTypeProperty]: + """Key properties""" + + return self._key + + def to_key_string_without_parentheses(self) -> str: + """Gets the string representation of the key without parentheses""" + + if self._type == EntityKey.TYPE_SINGLE: + # first property is the key property + key_prop = self._key[0] + return key_prop.typ.traits.to_literal(self._proprties[key_prop.name]) # type: ignore + + key_pairs = [] + for key_prop in self._key: + # if key_prop.name not in self.__dict__['_cache']: + # raise RuntimeError('Entity key is not complete, missing value of property: {0}'.format(key_prop.name)) + + key_pairs.append( + '{0}={1}'.format(key_prop.name, key_prop.typ.traits.to_literal(self._proprties[key_prop.name]))) + + return ','.join(key_pairs) + + def to_key_string(self) -> str: + """Gets the string representation of the key, including parentheses""" + + return '({})'.format(self.to_key_string_without_parentheses()) + + def __repr__(self) -> str: + return self.to_key_string() + + +class ODataHttpRequest: + """Deferred HTTP Request""" + + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + headers: Optional[Dict[str, str]] = None): + self._connection = connection + self._url = url + self._handler = handler + self._headers = headers + self._logger = logging.getLogger(LOGGER_NAME) + + @property + def handler(self) -> Callable[[requests.Response], Any]: + """Getter for handler""" + return self._handler + + def get_path(self) -> str: + """Get path of the HTTP request""" + # pylint: disable=no-self-use + return '' + + def get_query_params(self) -> Dict[Any, Any]: + """Get query params""" + # pylint: disable=no-self-use + return {} + + def get_method(self) -> str: + """Get HTTP method""" + # pylint: disable=no-self-use + return 'GET' + + def get_body(self) -> Optional[str]: + """Get HTTP body or None if not applicable""" + # pylint: disable=no-self-use + return None + + def get_headers(self) -> Optional[Dict[str, str]]: + """Get dict of HTTP headers""" + # pylint: disable=no-self-use + return None + + def execute(self) -> Any: + """Fetches HTTP response and returns processed result + + Sends the query-request to the OData service, returning a client-side Enumerable for + subsequent in-memory operations. + + Fetches HTTP response and returns processed result""" + + url = urljoin(self._url, self.get_path()) + # pylint: disable=assignment-from-none + body = self.get_body() + + headers = {} if self._headers is None else self._headers + + # pylint: disable=assignment-from-none + extra_headers = self.get_headers() + if extra_headers is not None: + headers.update(extra_headers) + + self._logger.debug('Send (execute) %s request to %s', self.get_method(), url) + self._logger.debug(' query params: %s', self.get_query_params()) + self._logger.debug(' headers: %s', headers) + if body: + self._logger.debug(' body: %s', body) + + response = self._connection.request( + self.get_method(), url, headers=headers, params=self.get_query_params(), data=body) + + self._logger.debug('Received response') + self._logger.debug(' url: %s', response.url) + self._logger.debug(' headers: %s', response.headers) + self._logger.debug(' status code: %d', response.status_code) + + try: + self._logger.debug(' body: %s', response.content.decode('utf-8')) + except UnicodeDecodeError: + self._logger.debug(' body: ') + + return self._handler(response) + + +class EntityGetRequest(ODataHttpRequest): + """Used for GET operations of a single entity""" + + def __init__(self, handler: Callable[[requests.Response], Any], entity_key: EntityKey, + entity_set_proxy: 'EntitySetProxy'): + super(EntityGetRequest, self).__init__(entity_set_proxy.service.url, entity_set_proxy.service.connection, + handler) + self._logger = logging.getLogger(LOGGER_NAME) + self._entity_key = entity_key + self._entity_set_proxy = entity_set_proxy + self._select: Optional[str] = None + self._expand: Optional[str] = None + + self._logger.debug('New instance of EntityGetRequest for last segment: %s', self._entity_set_proxy.last_segment) + + def nav(self, nav_property: str) -> 'EntitySetProxy': + """Navigates to given navigation property and returns the EntitySetProxy""" + return self._entity_set_proxy.nav(nav_property, self._entity_key) + + def select(self, select: str) -> 'EntityGetRequest': + """Specifies a subset of properties to return. + + @param select a comma-separated list of selection clauses + """ + self._select = select + return self + + def expand(self, expand: str) -> 'EntityGetRequest': + """Specifies related entities to expand inline as part of the response. + + @param expand a comma-separated list of navigation properties + """ + self._expand = expand + return self + + def get_path(self) -> str: + return str(self._entity_set_proxy.last_segment + self._entity_key.to_key_string()) + + def get_headers(self) -> Dict[str, str]: + return {'Accept': 'application/json'} + + def get_query_params(self) -> Dict[str, str]: + qparams = super(EntityGetRequest, self).get_query_params() + + if self._select is not None: + qparams['$select'] = self._select + + if self._expand is not None: + qparams['$expand'] = self._expand + + return qparams + + def get_value(self, connection: Optional[requests.Session] = None) -> ODataHttpRequest: + """Returns Value of Media EntityTypes also known as the $value URL suffix.""" + + if connection is None: + connection = self._connection + + def stream_handler(response: requests.Response) -> requests.Response: + """Returns $value from HTTP Response""" + + if response.status_code != requests.codes.ok: + raise HttpError('HTTP GET for $value failed with status code {}' + .format(response.status_code), response) + + return response + + return ODataHttpRequest( + urljoin(self._url, self.get_path(), '/$value'), + connection, + stream_handler) + + +class NavEntityGetRequest(EntityGetRequest): + """Used for GET operations of a single entity accessed via a Navigation property""" + + def __init__(self, handler: Callable[[requests.Response], Any], master_key: EntityKey, + entity_set_proxy: 'EntitySetProxy', + nav_property: str): + super(NavEntityGetRequest, self).__init__(handler, master_key, entity_set_proxy) + + self._nav_property = nav_property + + def get_path(self) -> str: + return "{}/{}".format(super(NavEntityGetRequest, self).get_path(), self._nav_property) + + +class EntityCreateRequest(ODataHttpRequest): + """Used for creating entities (POST operations of a single entity) + + Call execute() to send the create-request to the OData service + and get the newly created entity.""" + + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + entity_set: EntitySet, + last_segment: Optional[str] = None): + super(EntityCreateRequest, self).__init__(url, connection, handler) + self._logger = logging.getLogger(LOGGER_NAME) + self._entity_set = entity_set + self._entity_type = entity_set.entity_type + + if last_segment is None: + self._last_segment: str = self._entity_set.name + else: + self._last_segment = last_segment + + self._values: Dict[str, str] = {} + + # get all properties declared by entity type + self._type_props = self._entity_type.proprties() + + self._logger.debug('New instance of EntityCreateRequest for entity type: %s on path %s', self._entity_type.name, + self._last_segment) + + def get_path(self) -> str: + return self._last_segment + + def get_method(self) -> str: + # pylint: disable=no-self-use + return 'POST' + + def _get_body(self) -> Any: + """Recursively builds a dictionary of values where some of the values + might be another entities. + """ + + body = {} + for key, val in self._values.items(): + # The value is either an entity or a scalar + if isinstance(val, EntityProxy): + body[key] = val._get_body() # pylint: disable=protected-access + else: + body[key] = val + + return body + + def get_body(self) -> str: + return json.dumps(self._get_body()) + + def get_headers(self) -> Dict[str, str]: + return {'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'X'} + + @staticmethod + def _build_values(entity_type: EntityType, entity: Any) -> Any: + """Recursively converts a dictionary of values where some of the values + might be another entities (navigation properties) into the internal + representation. + """ + + if isinstance(entity, list): + return [EntityCreateRequest._build_values(entity_type, item) for item in entity] + + values = {} + for key, val in entity.items(): + try: + val = entity_type.proprty(key).typ.traits.to_json(val) # type: ignore + except PyODataModelError: + try: + nav_prop = entity_type.nav_proprty(key) # type: ignore + val = EntityCreateRequest._build_values(nav_prop.typ, val) + except PyODataModelError: + raise PyODataException('Property {} is not declared in {} entity type'.format( + key, entity_type.name)) + + values[key] = val + + return values + + def set(self, **kwargs: Any) -> 'EntityCreateRequest': + """Set properties on the new entity.""" + + self._logger.info(kwargs) + + # TODO: consider use of attset for setting properties + self._values = EntityCreateRequest._build_values(self._entity_type, kwargs) + + return self + + +class EntityDeleteRequest(ODataHttpRequest): + """Used for deleting entity (DELETE operations on a single entity)""" + + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + entity_set: EntitySet, + entity_key: EntityKey): + super(EntityDeleteRequest, self).__init__(url, connection, handler) + self._logger = logging.getLogger(LOGGER_NAME) + self._entity_set = entity_set + self._entity_key = entity_key + + self._logger.debug('New instance of EntityDeleteRequest for entity type: %s', entity_set.entity_type.name) + + def get_path(self) -> str: + return str(self._entity_set.name + self._entity_key.to_key_string()) + + def get_method(self) -> str: + # pylint: disable=no-self-use + return 'DELETE' + + +class EntityModifyRequest(ODataHttpRequest): + """Used for modyfing entities (UPDATE/MERGE operations on a single entity) + + Call execute() to send the update-request to the OData service + and get the modified entity.""" + + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + entity_set: EntitySet, entity_key: EntityKey): + super(EntityModifyRequest, self).__init__(url, connection, handler) + self._logger = logging.getLogger(LOGGER_NAME) + self._entity_set = entity_set + self._entity_type = entity_set.entity_type + self._entity_key = entity_key + + self._values: Dict[str, str] = {} + + # get all properties declared by entity type + self._type_props = self._entity_type.proprties() + + self._logger.debug('New instance of EntityModifyRequest for entity type: %s', self._entity_type.name) + + def get_path(self) -> str: + return str(self._entity_set.name + self._entity_key.to_key_string()) + + def get_method(self) -> str: + # pylint: disable=no-self-use + return 'PATCH' + + def get_body(self) -> str: + # pylint: disable=no-self-use + body = {} + for key, val in self._values.items(): + body[key] = val + return json.dumps(body) + + def get_headers(self) -> Dict[str, str]: + return {'Accept': 'application/json', 'Content-Type': 'application/json'} + + def set(self, **kwargs: Any) -> 'EntityModifyRequest': + """Set properties to be changed.""" + + self._logger.info(kwargs) + + for key, val in kwargs.items(): + try: + val = self._entity_type.proprty(key).typ.traits.to_json(val) + except KeyError: + raise PyODataException( + 'Property {} is not declared in {} entity type'.format(key, self._entity_type.name)) + + self._values[key] = val + + return self + + +class QueryRequest(ODataHttpRequest): + """INTERFACE A consumer-side query-request builder. Call execute() to issue the request.""" + + # pylint: disable=too-many-instance-attributes + + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + last_segment: str): + super(QueryRequest, self).__init__(url, connection, handler) + + self._logger = logging.getLogger(LOGGER_NAME) + self._count: Optional[bool] = None + self._top: Optional[int] = None + self._skip: Optional[int] = None + self._order_by: Optional[str] = None + self._filter: Optional[str] = None + self._select: Optional[str] = None + self._expand: Optional[str] = None + self._last_segment = last_segment + self._customs: Dict[str, str] = {} # string -> string hash + self._logger.debug('New instance of QueryRequest for last segment: %s', self._last_segment) + + def custom(self, name: str, value: str) -> 'QueryRequest': + """Adds a custom name-value pair.""" + # returns QueryRequest + self._customs[name] = value + return self + + def count(self) -> 'QueryRequest': + """Sets a flag to return the number of items.""" + self._count = True + return self + + def expand(self, expand: str) -> 'QueryRequest': + """Sets the expand expressions.""" + self._expand = expand + return self + + def filter(self, filter_val: str) -> 'QueryRequest': + """Sets the filter expression.""" + # returns QueryRequest + self._filter = filter_val + return self + + # def nav(self, key_value, nav_property): + # """Navigates to a referenced collection using a collection-valued navigation property.""" + # # returns QueryRequest + # raise NotImplementedError + + def order_by(self, order_by: str) -> 'QueryRequest': + """Sets the ordering expressions.""" + self._order_by = order_by + return self + + def select(self, select: str) -> 'QueryRequest': + """Sets the selection clauses.""" + self._select = select + return self + + def skip(self, skip: int) -> 'QueryRequest': + """Sets the number of items to skip.""" + self._skip = skip + return self + + def top(self, top: int) -> 'QueryRequest': + """Sets the number of items to return.""" + self._top = top + return self + + def get_path(self) -> str: + if self._count: + return urljoin(self._last_segment, '/$count') + + return self._last_segment + + def get_headers(self) -> Dict[str, str]: + if self._count: + return {} + + return { + 'Accept': 'application/json', + } + + def get_query_params(self) -> Dict[str, str]: + qparams = super(QueryRequest, self).get_query_params() + + if self._top is not None: + qparams['$top'] = self._top + + if self._skip is not None: + qparams['$skip'] = self._skip + + if self._order_by is not None: + qparams['$orderby'] = self._order_by + + if self._filter is not None: + qparams['$filter'] = self._filter + + if self._select is not None: + qparams['$select'] = self._select + + for key, val in self._customs.items(): + qparams[key] = val + + if self._expand is not None: + qparams['$expand'] = self._expand + + return qparams + + +class FunctionRequest(QueryRequest): + """Function import request (Service call)""" + + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + function_import: FunctionImport): + super(FunctionRequest, self).__init__(url, connection, handler, function_import.name) + + self._function_import = function_import + + self._logger.debug('New instance of FunctionRequest for %s', self._function_import.name) + + def parameter(self, name: str, value: int) -> 'FunctionRequest': + '''Sets value of parameter.''' + + # check if param is valid (is declared in metadata) + try: + param = self._function_import.get_parameter(name) # type: ignore + + # add parameter as custom query argument + self.custom(param.name, param.typ.traits.to_literal(value)) + except KeyError: + raise PyODataException('Function import {0} does not have pararmeter {1}' + .format(self._function_import.name, name)) + + return self + + def get_method(self) -> str: + return self._function_import.http_method # type: ignore + + def get_headers(self) -> Dict[str, str]: + return { + 'Accept': 'application/json', + } + + +class EntityProxy: + """An immutable OData entity instance, consisting of an identity (an + entity-set and a unique entity-key within that set), properties (typed, + named values), and links (references to other entities). + """ + + # pylint: disable=too-many-branches,too-many-nested-blocks, unused-argument + + def __init__(self, service: 'Service', entity_set: Union[EntitySet, 'EntitySetProxy', None], + entity_type: EntityType, proprties: Optional[Any] = None, entity_key: Optional[EntityKey] = None): + # Mark V4 changes + self._logger = logging.getLogger(LOGGER_NAME) + self._service = service + self._entity_set = entity_set + + self._entity_type = entity_type + self._key_props = entity_type.key_proprties + self._cache: Dict[str, Any] = dict() + self._entity_key = None # entity_key + # self._logger.debug('New entity proxy instance of type %s from properties: %s', entity_type.name, proprties) + + # cache values of individual properties if provided + if proprties is not None: + + # first, cache values of direct properties + for type_proprty in self._entity_type.proprties(): # type: ignore + if type_proprty.name in proprties: + if proprties[type_proprty.name] is not None: + self._cache[type_proprty.name] = type_proprty.typ.traits.from_json(proprties[type_proprty.name]) + else: + # null value is in literal form for now, convert it to python representation + self._cache[type_proprty.name] = type_proprty.typ.traits.from_literal( + type_proprty.typ.null_value) + + # then, assign all navigation properties + # for prop in self._entity_type.nav_proprties: + + # if prop.name in proprties: + + # entity type of navigation property + # prop_etype = prop.to_role.entity_type + + # cache value according to multiplicity + # if prop.to_role.multiplicity in \ + # [elements_v4.EndRole.MULTIPLICITY_ONE, + # elements_v4.EndRole.MULTIPLICITY_ZERO_OR_ONE]: + # + # # cache None in case we receive nothing (null) instead of entity data + # if proprties[prop.name] is None: + # self._cache[prop.name] = None + # else: + # self._cache[prop.name] = EntityProxy(service, None, prop_etype, proprties[prop.name]) + # + # elif prop.to_role.multiplicity == elements_v4.EndRole.MULTIPLICITY_ZERO_OR_MORE: + # # default value is empty array + # self._cache[prop.name] = [] + # + # # if there are no entities available, received data consists of + # # metadata properties only. + # if 'results' in proprties[prop.name]: + # + # # available entities are serialized in results array + # for entity in proprties[prop.name]['results']: + # self._cache[prop.name].append(EntityProxy(service, None, prop_etype, entity)) + # else: + # raise PyODataException('Unknown multiplicity {0} of association role {1}' + # .format(prop.to_role.multiplicity, prop.to_role.name)) + + # build entity key if not provided + if self._entity_key is None: + # try to build key from available property values + try: + # if key seems to be simple (consists of single property) + if len(self._key_props) == 1: + self._entity_key = EntityKey(entity_type, self._cache[self._key_props[0].name]) + else: + # build complex key + self._entity_key = EntityKey(entity_type, **self._cache) + except KeyError: + pass + except PyODataException: + pass + + def __repr__(self) -> str: + return self._entity_key.to_key_string() + # entity_key = self._entity_key + # if entity_key is None: + # raise PyODataException('Entity key is None') + + # return entity_key.to_key_string() + + def __getattr__(self, attr: str) -> Any: + try: + return self._cache[attr] + except KeyError: + try: + value = self.get_proprty(attr).execute() + self._cache[attr] = value + return value + except KeyError as ex: + raise AttributeError('EntityType {0} does not have Property {1}: {2}' + .format(self._entity_type.name, attr, str(ex))) + + def nav(self, nav_property: str) -> Union['NavEntityProxy', 'EntitySetProxy']: + """Navigates to given navigation property and returns the EntitySetProxy""" + + # for now duplicated with simillar method in entity set proxy class + try: + navigation_property = self._entity_type.nav_proprty(nav_property) # type: ignore + except KeyError: + raise PyODataException('Navigation property {} is not declared in {} entity type'.format( + nav_property, self._entity_type)) + + # Get entity set of navigation property + association_info = navigation_property.association_info + association_set = self._service.schema.association_set_by_association( # type: ignore + association_info.name, + association_info.namespace) + + navigation_entity_set = None + for end in association_set.end_roles: + if association_set.end_by_entity_set(end.entity_set_name).role == navigation_property.to_role.role: + navigation_entity_set = self._service.schema.entity_set(end.entity_set_name, + association_info.namespace) # type: ignore + + if not navigation_entity_set: + raise PyODataException('No association set for role {}'.format(navigation_property.to_role)) + + # roles = navigation_property.association.end_roles + # if all((role.multiplicity != elements_v4.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): + # return NavEntityProxy(self, nav_property, navigation_entity_set.entity_type, {}) + + return EntitySetProxy( + self._service, + self._service.schema.entity_set(navigation_entity_set.name), # type: ignore + nav_property, + self._entity_set.name + self._entity_key.to_key_string()) # type: ignore + + def get_path(self) -> str: + """Returns this entity's relative path - e.g. EntitySet(KEY)""" + return str(self._entity_set._name + self._entity_key.to_key_string()) # pylint: disable=protected-access + + def get_proprty(self, name: str, connection: Optional[requests.Session] = None) -> ODataHttpRequest: + """Returns value of the property""" + + self._logger.info('Initiating property request for %s', name) + + def proprty_get_handler(key: str, proprty: VariableDeclaration, response: requests.Response) -> Any: + """Gets property value from HTTP Response""" + + if response.status_code != requests.codes.ok: + raise HttpError('HTTP GET for Attribute {0} of Entity {1} failed with status code {2}' + .format(proprty.name, key, response.status_code), response) + + data = response.json()['d'] + return proprty.typ.traits.from_json(data[proprty.name]) + + path = urljoin(self.get_path(), name) + return self._service.http_get_odata( + path, + partial(proprty_get_handler, path, self._entity_type.proprty(name)), + connection=connection) + + def get_value(self, connection: Optional[requests.Session] = None) -> ODataHttpRequest: + "Returns $value of Stream entities" + + def value_get_handler(key: Any, response: requests.Response) -> requests.Response: + """Gets property value from HTTP Response""" + + if response.status_code != requests.codes.ok: + raise HttpError('HTTP GET for $value of Entity {0} failed with status code {1}' + .format(key, response.status_code), response) + + return response + + path = urljoin(self.get_path(), '/$value') + return self._service.http_get_odata(path, + partial(value_get_handler, self.entity_key), + connection=connection) + + @property + def entity_set(self) -> Optional[Union['EntitySet', 'EntitySetProxy']]: + """Entity set related to this entity""" + + return self._entity_set + + @property + def entity_key(self) -> Optional[EntityKey]: + """Key of entity""" + + return self._entity_key + + @property + def url(self) -> str: + """URL of the real entity""" + + service_url = self._service.url.rstrip('/') + entity_path = self.get_path() + + return urljoin(service_url, entity_path) + + def equals(self, other: 'EntityProxy') -> bool: + """Returns true if the self and the other contains the same data""" + # pylint: disable=W0212 + return self._cache == other._cache + + +class NavEntityProxy(EntityProxy): + """Special case of an Entity access via 1 to 1 Navigation property""" + + def __init__(self, parent_entity: EntityProxy, prop_name: str, entity_type: EntityType, entity: Dict[str, str]): + # pylint: disable=protected-access + super(NavEntityProxy, self).__init__(parent_entity._service, parent_entity._entity_set, entity_type, entity) + + self._parent_entity = parent_entity + self._prop_name = prop_name + + def get_path(self) -> str: + """Returns URL of the entity""" + + return urljoin(self._parent_entity.get_path(), self._prop_name) + + +class GetEntitySetFilter: + """Create filters for humans""" + + def __init__(self, proprty: StructTypeProperty): + self._proprty = proprty + + @staticmethod + def build_expression(operator: str, operands: Tuple[str, ...]) -> str: + """Creates a expression by joining the operands with the operator""" + + if len(operands) < 2: + raise ExpressionError('The $filter operator \'{}\' needs at least two operands'.format(operator)) + + return '({})'.format(' {} '.format(operator).join(operands)) + + @staticmethod + def and_(*operands: str) -> str: + """Creates logical AND expression from the operands""" + + return GetEntitySetFilter.build_expression('and', operands) + + @staticmethod + def or_(*operands: str) -> str: + """Creates logical OR expression from the operands""" + + return GetEntitySetFilter.build_expression('or', operands) + + @staticmethod + def format_filter(proprty: StructTypeProperty, operator: str, value: str) -> str: + """Creates a filter expression """ + + return '{} {} {}'.format(proprty.name, operator, proprty.typ.traits.to_literal(value)) + + def __eq__(self, value: str) -> str: # type: ignore + return GetEntitySetFilter.format_filter(self._proprty, 'eq', value) + + def __ne__(self, value: str) -> str: # type: ignore + return GetEntitySetFilter.format_filter(self._proprty, 'ne', value) + + +class GetEntitySetRequest(QueryRequest): + """GET on EntitySet""" + + def __init__(self, url: str, connection: requests.Session, handler: Callable[[requests.Response], Any], + last_segment: str, entity_type: EntityType): + super(GetEntitySetRequest, self).__init__(url, connection, handler, last_segment) + + self._entity_type = entity_type + + def __getattr__(self, name: str) -> GetEntitySetFilter: + proprty = self._entity_type.proprty(name) + return GetEntitySetFilter(proprty) + + +class EntitySetProxy: + """EntitySet Proxy""" + + def __init__(self, service: 'Service', entity_set: EntitySet, alias: Optional[str] = None, + parent_last_segment: Optional[str] = None): + """Creates new Entity Set object + + @param alias in case the entity set is access via assossiation + @param parent_last_segment in case of association also parent key must be used + """ + self._service = service + self._entity_set = entity_set + self._alias = alias + if parent_last_segment is None: + self._parent_last_segment = '' + else: + if parent_last_segment.endswith('/'): + self._parent_last_segment = parent_last_segment + else: + self._parent_last_segment = parent_last_segment + '/' + self._name = entity_set.name + self._key = entity_set.entity_type.key_proprties + self._logger = logging.getLogger(LOGGER_NAME) + + self._logger.debug('New entity set proxy instance for %s', self._name) + + @property + def service(self) -> 'Service': + """Return service""" + return self._service + + @property + def last_segment(self) -> str: + """Return last segment of url""" + + entity_set_name: str = self._alias if self._alias is not None else self._entity_set.name + return self._parent_last_segment + entity_set_name + + def nav(self, nav_property: str, key: EntityKey) -> 'EntitySetProxy': + """Navigates to given navigation property and returns the EntitySetProxy""" + + try: + navigation_property = self._entity_set.entity_type.nav_proprty(nav_property) + except KeyError: + raise PyODataException('Navigation property {} is not declared in {} entity type'.format( + nav_property, self._entity_set.entity_type)) + + # Get entity set of navigation property + association_info = navigation_property.association_info + association_set = self._service.schema.association_set_by_association( + association_info.name) + + navigation_entity_set = None + for end in association_set.end_roles: + if association_set.end_by_entity_set(end.entity_set_name).role == navigation_property.to_role.role: + navigation_entity_set = self._service.schema.entity_set(end.entity_set_name) + + if not navigation_entity_set: + raise PyODataException( + 'No association set for role {} {}'.format(navigation_property.to_role, association_set.end_roles)) + + # roles = navigation_property.association.end_roles + # if all((role.multiplicity != elements_v4.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): + # return self._get_nav_entity(key, nav_property, navigation_entity_set) + + return EntitySetProxy( + self._service, + navigation_entity_set, + nav_property, + self._entity_set.name + key.to_key_string()) + + def _get_nav_entity(self, master_key: EntityKey, nav_property: str, + navigation_entity_set: EntitySet) -> NavEntityGetRequest: + """Get entity based on provided key of the master and Navigation property name""" + + def get_entity_handler(parent: EntityProxy, nav_property: str, navigation_entity_set: EntitySet, + response: requests.Response) -> NavEntityProxy: + """Gets entity from HTTP response""" + + if response.status_code != requests.codes.ok: + raise HttpError('HTTP GET for Entity {0} failed with status code {1}' + .format(self._name, response.status_code), response) + + entity = response.json()['d'] + + return NavEntityProxy(parent, nav_property, navigation_entity_set.entity_type, entity) + + self._logger.info( + 'Getting the nav property %s of the entity %s for the key %s', + nav_property, + self._entity_set.entity_type.name, + master_key) + + parent = EntityProxy(self._service, self, self._entity_set.entity_type, entity_key=master_key) + + return NavEntityGetRequest( + partial(get_entity_handler, parent, nav_property, navigation_entity_set), + master_key, + self, + nav_property) + + def get_entity(self, key=None, **args) -> EntityGetRequest: + """Get entity based on provided key properties""" + + def get_entity_handler(response: requests.Response) -> EntityProxy: + """Gets entity from HTTP response""" + + if response.status_code != requests.codes.ok: + raise HttpError('HTTP GET for Entity {0} failed with status code {1}' + .format(self._name, response.status_code), response) + + entity = response.json()['d'] + + return EntityProxy(self._service, self._entity_set, self._entity_set.entity_type, entity) + + if key is not None and isinstance(key, EntityKey): + entity_key = key + else: + entity_key = EntityKey(self._entity_set.entity_type, key, **args) + + self._logger.info('Getting entity %s for key %s and args %s', self._entity_set.entity_type.name, key, args) + + return EntityGetRequest(get_entity_handler, entity_key, self) + + def get_entities(self): + """Get all entities""" + + def get_entities_handler(response: requests.Response) -> Union[List[EntityProxy], int]: + """Gets entity set from HTTP Response""" + + if response.status_code != requests.codes.ok: + raise HttpError('HTTP GET for Entity Set {0} failed with status code {1}' + .format(self._name, response.status_code), response) + + content = response.json() + + if isinstance(content, int): + return content + + entities = content['d']['results'] + + result = [] + for props in entities: + entity = EntityProxy(self._service, self._entity_set, self._entity_set.entity_type, props) + result.append(entity) + + return result + + entity_set_name = self._alias if self._alias is not None else self._entity_set.name + return GetEntitySetRequest(self._service.url, self._service.connection, get_entities_handler, + self._parent_last_segment + entity_set_name, self._entity_set.entity_type) + + def create_entity(self, return_code: int = requests.codes.created) -> EntityCreateRequest: + """Creates a new entity in the given entity-set.""" + + def create_entity_handler(response: requests.Response) -> EntityProxy: + """Gets newly created entity encoded in HTTP Response""" + + if response.status_code != return_code: + raise HttpError('HTTP POST for Entity Set {0} failed with status code {1}' + .format(self._name, response.status_code), response) + # Mark ODataV4 changes + entity_props = response.json() + + return EntityProxy(self._service, self._entity_set, self._entity_set.entity_type, entity_props) + + return EntityCreateRequest(self._service.url, self._service.connection, create_entity_handler, self._entity_set, + self.last_segment) + + def update_entity(self, key=None, **kwargs) -> EntityModifyRequest: + """Updates an existing entity in the given entity-set.""" + + def update_entity_handler(response: requests.Response) -> None: + """Gets modified entity encoded in HTTP Response""" + + if response.status_code != 204: + raise HttpError('HTTP modify request for Entity Set {} failed with status code {}' + .format(self._name, response.status_code), response) + + if key is not None and isinstance(key, EntityKey): + entity_key = key + else: + entity_key = EntityKey(self._entity_set.entity_type, key, **kwargs) + + self._logger.info('Updating entity %s for key %s and args %s', self._entity_set.entity_type.name, key, kwargs) + + return EntityModifyRequest(self._service.url, self._service.connection, update_entity_handler, self._entity_set, + entity_key) + + def delete_entity(self, key: Optional[EntityKey] = None, **kwargs: Any) -> EntityDeleteRequest: + """Delete the entity""" + + def delete_entity_handler(response: requests.Response) -> None: + """Check if entity deletion was successful""" + + if response.status_code != 204: + raise HttpError(f'HTTP POST for Entity delete {self._name} ' + f'failed with status code {response.status_code}', + response) + + if key is not None and isinstance(key, EntityKey): + entity_key = key + else: + entity_key = EntityKey(self._entity_set.entity_type, key, **kwargs) + + return EntityDeleteRequest(self._service.url, self._service.connection, delete_entity_handler, self._entity_set, + entity_key) + + +# pylint: disable=too-few-public-methods +class EntityContainer: + """Set of EntitSet proxies""" + + def __init__(self, service: 'Service'): + self._service = service + + self._entity_sets: Dict[str, EntitySetProxy] = dict() + + for entity_set in self._service.schema.entity_sets: + self._entity_sets[entity_set.name] = EntitySetProxy(self._service, entity_set) + + def __getattr__(self, name: str) -> EntitySetProxy: + try: + return self._entity_sets[name] + except KeyError: + raise AttributeError( + 'EntitySet {0} not defined in {1}.'.format(name, ','.join(list(self._entity_sets.keys())))) + + +class FunctionContainer: + """Set of Function proxies + + Call a server-side functions (also known as a service operation). + """ + + def __init__(self, service: 'Service'): + self._service = service + + self._functions: Dict[str, FunctionImport] = dict() + + for fimport in self._service.schema.function_imports: + self._functions[fimport.name] = fimport + + def __getattr__(self, name: str) -> FunctionRequest: + + if name not in self._functions: + raise AttributeError( + 'Function {0} not defined in {1}.'.format(name, ','.join(list(self._functions.keys())))) + + fimport = self._service.schema.function_import(name) # type: ignore + + def function_import_handler(fimport: FunctionImport, + response: requests.Response) -> Union[EntityProxy, None, Any]: + """Get function call response from HTTP Response""" + + if 300 <= response.status_code < 400: + raise HttpError(f'Function Import {fimport.name} requires Redirection which is not supported', + response) + + if response.status_code == 401: + raise HttpError(f'Not authorized to call Function Import {fimport.name}', + response) + + if response.status_code == 403: + raise HttpError(f'Missing privileges to call Function Import {fimport.name}', + response) + + if response.status_code == 405: + raise HttpError( + f'Despite definition Function Import {fimport.name} does not support HTTP {fimport.http_method}', + response) + + if 400 <= response.status_code < 500: + raise HttpError( + f'Function Import {fimport.name} call has failed with status code {response.status_code}', + response) + + if response.status_code >= 500: + raise HttpError(f'Server has encountered an error while processing Function Import {fimport.name}', + response) + + if fimport.return_type is None: + if response.status_code != 204: + logging.getLogger(LOGGER_NAME).warning( + 'The No Return Function Import %s has replied with HTTP Status Code %d instead of 204', + fimport.name, response.status_code) + + if response.text: + logging.getLogger(LOGGER_NAME).warning( + 'The No Return Function Import %s has returned content:\n%s', fimport.name, response.text) + + return None + + if response.status_code != 200: + logging.getLogger(LOGGER_NAME).warning( + 'The Function Import %s has replied with HTTP Status Code %d instead of 200', + fimport.name, response.status_code) + + response_data = response.json()['d'] + + # 1. if return types is "entity type", return instance of appropriate entity proxy + if isinstance(fimport.return_type, elements.EntityType): + entity_set = self._service.schema.entity_set(fimport.entity_set_name) # type: ignore + return EntityProxy(self._service, entity_set, fimport.return_type, response_data) + + # 2. return raw data for all other return types (primitives, complex types encoded in dicts, etc.) + return response_data + + return FunctionRequest(self._service.url, self._service.connection, + partial(function_import_handler, fimport), fimport) + + +class Service: + """OData service""" + + def __init__(self, url: str, schema: elements.Schema, connection: requests.Session): + self._url = url + self._schema = schema + self._connection = connection + self._entity_container = EntityContainer(self) + self._function_container = FunctionContainer(self) + + @property + def schema(self) -> elements.Schema: + """Parsed metadata""" + + return self._schema + + @property + def url(self) -> str: + """Service url""" + + return self._url + + @property + def connection(self) -> requests.Session: + """Service connection""" + + return self._connection + + @property + def entity_sets(self) -> EntityContainer: + """EntitySet proxy""" + + return self._entity_container + + @property + def functions(self) -> FunctionContainer: + """Functions proxy""" + + return self._function_container + + def http_get(self, path: str, connection: Optional[requests.Session] = None) -> requests.Response: + """HTTP GET response for the passed path in the service""" + + conn = connection + if conn is None: + conn = self._connection + + return conn.get(urljoin(self._url, path)) + + def http_get_odata(self, path: str, handler: Callable[[requests.Response], Any], + connection: Optional[requests.Session] = None) -> ODataHttpRequest: + """HTTP GET request proxy for the passed path in the service""" + + conn = connection + if conn is None: + conn = self._connection + + return ODataHttpRequest( + urljoin(self._url, path), + conn, + handler, + headers={'Accept': 'application/json'}) + + def create_batch(self, batch_id: Optional[str] = None) -> 'BatchRequest': + """Create instance of OData batch request""" + + def batch_handler(batch: MultipartRequest, parts: List[List[str]]) -> List[Any]: + """Process parsed multipart request (parts)""" + + logging.getLogger(LOGGER_NAME).debug('Batch handler called for batch %s', batch.id) + + result: List[Any] = [] + for part, req in zip(parts, batch.requests): + logging.getLogger(LOGGER_NAME).debug('Batch handler is processing part %s for request %s', part, req) + + # if part represents multiple requests, dont' parse body and + # process parts by appropriate reuqest instance + if isinstance(req, MultipartRequest): + result.append(req.handler(req, part)) + else: + # part represents single request, we have to parse + # content (without checking Content type for binary/http) + response = ODataHttpResponse.from_string(part[0]) + result.append(req.handler(response)) + return result + + return BatchRequest(self._url, self._connection, batch_handler, batch_id) + + def create_changeset(self, changeset_id=None): + """Create instance of OData changeset""" + + def changeset_handler(changeset: 'Changeset', parts: List[str]) -> List[ODataHttpResponse]: + """Gets changeset response from HTTP response""" + + logging.getLogger(LOGGER_NAME).debug('Changeset handler called for changeset %s', changeset.id) + + result: List[ODataHttpResponse] = [] + + # check if changeset response consists of parts, this is important + # to distinguish cases when server responds with single HTTP response + # for whole request + if not isinstance(parts[0], list): + # raise error (even for successfull status codes) since such changeset response + # always means something wrong happened on server + response = ODataHttpResponse.from_string(parts[0]) + raise HttpError('Changeset cannot be processed due to single response received, status code: {}'.format( + response.status_code), response) + + for part, req in zip(parts, changeset.requests): + logging.getLogger(LOGGER_NAME).debug('Changeset handler is processing part %s for request %s', part, + req) + + if isinstance(req, MultipartRequest): + raise PyODataException('Changeset cannot contain nested multipart content') + + # part represents single request, we have to parse + # content (without checking Content type for binary/http) + response = ODataHttpResponse.from_string(part[0]) + + result.append(req.handler(response)) + + return result + + return Changeset(self._url, self._connection, changeset_handler, changeset_id) + + +class MultipartRequest(ODataHttpRequest): + """HTTP Batch request""" + + def __init__(self, url: str, connection: requests.Session, handler: Callable[[ODataHttpResponse], Any], + request_id: Optional[str] = None): + super(MultipartRequest, self).__init__(url, connection, partial(MultipartRequest.http_response_handler, self)) + + self.requests: List[ODataHttpRequest] = [] + self._handler_decoded = handler + + # generate random id of form dddd-dddd-dddd + # pylint: disable=invalid-name + self.id = request_id if request_id is not None else '{}_{}_{}'.format( + random.randint(1000, 9999), random.randint(1000, 9999), random.randint(1000, 9999)) + + self._logger.debug('New multipart %s request initialized, id=%s', self.__class__.__name__, self.id) + + @property + def handler(self) -> Callable[['ODataHttpResponse'], Any]: + return self._handler_decoded + + def get_boundary(self) -> str: + """Get boundary used for request parts""" + return self.id + + def get_headers(self) -> Dict[str, str]: + # pylint: disable=no-self-use + return {'Content-Type': 'multipart/mixed;boundary={}'.format(self.get_boundary())} + + def get_body(self) -> str: + return encode_multipart(self.get_boundary(), self.requests) + + def add_request(self, request: ODataHttpRequest) -> None: + """Add request to be sent in batch""" + + self.requests.append(request) + self._logger.debug('New %s request added to multipart request %s', request.get_method(), self.id) + + @staticmethod + def http_response_handler(request: 'MultipartRequest', response: requests.Response) -> Any: + """Process HTTP response to mutipart HTTP request""" + + if response.status_code != 202: # 202 Accepted + raise HttpError('HTTP POST for multipart request {0} failed with status code {1}' + .format(request.id, response.status_code), response) + + logging.getLogger(LOGGER_NAME).debug('Generic multipart http response request handler called') + + # get list of all parts (headers + body) + decoded = decode_multipart(response.content.decode('utf-8'), response.headers['Content-Type']) + + return request.handler(request, decoded) + + +class BatchRequest(MultipartRequest): + """HTTP Batch request""" + + def get_boundary(self) -> str: + return str('batch_' + self.id) + + def get_path(self) -> str: + # pylint: disable=no-self-use + return '$batch' + + def get_method(self) -> str: + # pylint: disable=no-self-use + return 'POST' + + +class Changeset(MultipartRequest): + """Representation of changeset (unsorted group of requests)""" + + def get_boundary(self) -> str: + return 'changeset_' + self.id diff --git a/pyodata/version.py b/pyodata/version.py index bdfdcd3b..ce6a74f9 100644 --- a/pyodata/version.py +++ b/pyodata/version.py @@ -4,6 +4,7 @@ from typing import List, Dict, Callable, TYPE_CHECKING # pylint: disable=cyclic-import + if TYPE_CHECKING: from pyodata.model.elements import Typ, Annotation # noqa @@ -34,3 +35,6 @@ def build_functions() -> Dict[type, Callable]: @abstractmethod def annotations() -> Dict['Annotation', Callable]: """ Here we define which annotations are supported and what is their python representation""" + # + # @staticmethod + # def init_service(url: str, schema: 'Schema', connection: requests.Session) -> Service diff --git a/tests/olingo_server/Dockerfile b/tests/olingo_server/Dockerfile deleted file mode 100644 index 4ed44cec..00000000 --- a/tests/olingo_server/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM maven:3.6.0-jdk-8-alpine AS MAVEN_TOOL_CHAIN -COPY pom.xml /tmp/ -COPY src /tmp/src/ -WORKDIR /tmp/ -RUN mvn clean install - -FROM tomcat:9.0-jre8-alpine -COPY --from=MAVEN_TOOL_CHAIN /tmp/target/odata-server*.war $CATALINA_HOME/webapps/odata-server.war - -EXPOSE 8080 - -CMD ["catalina.sh", "run"] diff --git a/tests/olingo_server/pom.xml b/tests/olingo_server/pom.xml deleted file mode 100644 index e60d4add..00000000 --- a/tests/olingo_server/pom.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - 4.0.0 - - org.apache.olingo - odata-server - war - 1.0 - ${project.artifactId} - - - 4.2.0 - - - - - javax.servlet - servlet-api - 2.5 - provided - - - - org.apache.olingo - odata-server-api - ${odata.version} - - - org.apache.olingo - odata-server-core - ${odata.version} - runtime - - - - org.apache.olingo - odata-commons-api - ${odata.version} - - - org.apache.olingo - odata-commons-core - ${odata.version} - - - - org.slf4j - slf4j-api - 1.7.7 - - - - org.slf4j - slf4j-simple - 1.7.7 - runtime - - - diff --git a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/CarsServlet.java b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/CarsServlet.java deleted file mode 100644 index 7812f6a1..00000000 --- a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/CarsServlet.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.olingo.server.sample; - -import java.io.IOException; -import java.util.ArrayList; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; - -import org.apache.olingo.commons.api.edmx.EdmxReference; -import org.apache.olingo.server.api.OData; -import org.apache.olingo.server.api.ODataHttpHandler; -import org.apache.olingo.server.api.ServiceMetadata; -import org.apache.olingo.server.sample.data.DataProvider; -import org.apache.olingo.server.sample.edmprovider.CarsEdmProvider; -import org.apache.olingo.server.sample.processor.CarsProcessor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class CarsServlet extends HttpServlet { - - private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(CarsServlet.class); - - @Override - protected void service(final HttpServletRequest req, final HttpServletResponse resp) - throws ServletException, IOException { - try { - HttpSession session = req.getSession(true); - DataProvider dataProvider = (DataProvider) session.getAttribute(DataProvider.class.getName()); - if (dataProvider == null) { - dataProvider = new DataProvider(); - session.setAttribute(DataProvider.class.getName(), dataProvider); - LOG.info("Created new data provider."); - } - - OData odata = OData.newInstance(); - ServiceMetadata edm = odata.createServiceMetadata(new CarsEdmProvider(), new ArrayList()); - ODataHttpHandler handler = odata.createHandler(edm); - handler.register(new CarsProcessor(dataProvider)); - handler.process(req, resp); - } catch (RuntimeException e) { - LOG.error("Server Error", e); - throw new ServletException(e); - } - } -} diff --git a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/data/DataProvider.java b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/data/DataProvider.java deleted file mode 100644 index 191a588d..00000000 --- a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/data/DataProvider.java +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.olingo.server.sample.data; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.apache.olingo.commons.api.ex.ODataException; -import org.apache.olingo.commons.api.ex.ODataRuntimeException; -import org.apache.olingo.commons.api.data.Entity; -import org.apache.olingo.commons.api.data.EntityCollection; -import org.apache.olingo.commons.api.data.Property; -import org.apache.olingo.commons.api.data.ValueType; -import org.apache.olingo.commons.api.data.ComplexValue; -import org.apache.olingo.commons.api.edm.EdmEntitySet; -import org.apache.olingo.commons.api.edm.EdmEntityType; -import org.apache.olingo.commons.api.edm.EdmPrimitiveType; -import org.apache.olingo.commons.api.edm.EdmPrimitiveTypeException; -import org.apache.olingo.commons.api.edm.EdmProperty; -import org.apache.olingo.server.api.uri.UriParameter; -import org.apache.olingo.server.sample.edmprovider.CarsEdmProvider; - -public class DataProvider { - - private final Map data; - - public DataProvider() { - data = new HashMap(); - data.put("Cars", createCars()); - data.put("Manufacturers", createManufacturers()); - } - - public EntityCollection readAll(EdmEntitySet edmEntitySet) { - return data.get(edmEntitySet.getName()); - } - - public Entity read(final EdmEntitySet edmEntitySet, final List keys) throws DataProviderException { - final EdmEntityType entityType = edmEntitySet.getEntityType(); - final EntityCollection entitySet = data.get(edmEntitySet.getName()); - if (entitySet == null) { - return null; - } else { - try { - for (final Entity entity : entitySet.getEntities()) { - boolean found = true; - for (final UriParameter key : keys) { - final EdmProperty property = (EdmProperty) entityType.getProperty(key.getName()); - final EdmPrimitiveType type = (EdmPrimitiveType) property.getType(); - if (!type.valueToString(entity.getProperty(key.getName()).getValue(), - property.isNullable(), property.getMaxLength(), property.getPrecision(), property.getScale(), - property.isUnicode()) - .equals(key.getText())) { - found = false; - break; - } - } - if (found) { - return entity; - } - } - return null; - } catch (final EdmPrimitiveTypeException e) { - throw new DataProviderException("Wrong key!", e); - } - } - } - - public static class DataProviderException extends ODataException { - private static final long serialVersionUID = 5098059649321796156L; - - public DataProviderException(String message, Throwable throwable) { - super(message, throwable); - } - - public DataProviderException(String message) { - super(message); - } - } - - private EntityCollection createCars() { - EntityCollection entitySet = new EntityCollection(); - Entity el = new Entity() - .addProperty(createPrimitive("Id", 1)) - .addProperty(createPrimitive("Model", "F1 W03")) - .addProperty(createPrimitive("ModelYear", "2012")) - .addProperty(createPrimitive("Price", 189189.43)) - .addProperty(createPrimitive("Currency", "EUR")); - el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 1)); - entitySet.getEntities().add(el); - - el = new Entity() - .addProperty(createPrimitive("Id", 2)) - .addProperty(createPrimitive("Model", "F1 W04")) - .addProperty(createPrimitive("ModelYear", "2013")) - .addProperty(createPrimitive("Price", 199999.99)) - .addProperty(createPrimitive("Currency", "EUR")); - el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 2)); - entitySet.getEntities().add(el); - - el = new Entity() - .addProperty(createPrimitive("Id", 3)) - .addProperty(createPrimitive("Model", "F2012")) - .addProperty(createPrimitive("ModelYear", "2012")) - .addProperty(createPrimitive("Price", 137285.33)) - .addProperty(createPrimitive("Currency", "EUR")); - el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 3)); - entitySet.getEntities().add(el); - - el = new Entity() - .addProperty(createPrimitive("Id", 4)) - .addProperty(createPrimitive("Model", "F2013")) - .addProperty(createPrimitive("ModelYear", "2013")) - .addProperty(createPrimitive("Price", 145285.00)) - .addProperty(createPrimitive("Currency", "EUR")); - el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 4)); - entitySet.getEntities().add(el); - - el = new Entity() - .addProperty(createPrimitive("Id", 5)) - .addProperty(createPrimitive("Model", "F1 W02")) - .addProperty(createPrimitive("ModelYear", "2011")) - .addProperty(createPrimitive("Price", 167189.00)) - .addProperty(createPrimitive("Currency", "EUR")); - el.setId(createId(CarsEdmProvider.ES_CARS_NAME, 5)); - entitySet.getEntities().add(el); - - for (Entity entity:entitySet.getEntities()) { - entity.setType(CarsEdmProvider.ET_CAR.getFullQualifiedNameAsString()); - } - return entitySet; - } - - private EntityCollection createManufacturers() { - EntityCollection entitySet = new EntityCollection(); - - Entity el = new Entity() - .addProperty(createPrimitive("Id", 1)) - .addProperty(createPrimitive("Name", "Star Powered Racing")) - .addProperty(createAddress("Star Street 137", "Stuttgart", "70173", "Germany")); - el.setId(createId(CarsEdmProvider.ES_MANUFACTURER_NAME, 1)); - entitySet.getEntities().add(el); - - el = new Entity() - .addProperty(createPrimitive("Id", 2)) - .addProperty(createPrimitive("Name", "Horse Powered Racing")) - .addProperty(createAddress("Horse Street 1", "Maranello", "41053", "Italy")); - el.setId(createId(CarsEdmProvider.ES_MANUFACTURER_NAME, 2)); - entitySet.getEntities().add(el); - - for (Entity entity:entitySet.getEntities()) { - entity.setType(CarsEdmProvider.ET_MANUFACTURER.getFullQualifiedNameAsString()); - } - return entitySet; - } - - private Property createAddress(final String street, final String city, final String zipCode, final String country) { - ComplexValue complexValue=new ComplexValue(); - List addressProperties = complexValue.getValue(); - addressProperties.add(createPrimitive("Street", street)); - addressProperties.add(createPrimitive("City", city)); - addressProperties.add(createPrimitive("ZipCode", zipCode)); - addressProperties.add(createPrimitive("Country", country)); - return new Property(null, "Address", ValueType.COMPLEX, complexValue); - } - - private Property createPrimitive(final String name, final Object value) { - return new Property(null, name, ValueType.PRIMITIVE, value); - } - - private URI createId(String entitySetName, Object id) { - try { - return new URI(entitySetName + "(" + String.valueOf(id) + ")"); - } catch (URISyntaxException e) { - throw new ODataRuntimeException("Unable to create id for entity: " + entitySetName, e); - } - } -} diff --git a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/edmprovider/CarsEdmProvider.java b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/edmprovider/CarsEdmProvider.java deleted file mode 100644 index 628ad7d2..00000000 --- a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/edmprovider/CarsEdmProvider.java +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.olingo.server.sample.edmprovider; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import org.apache.olingo.commons.api.ex.ODataException; -import org.apache.olingo.commons.api.edm.EdmPrimitiveTypeKind; -import org.apache.olingo.commons.api.edm.FullQualifiedName; -import org.apache.olingo.commons.api.edm.provider.CsdlAbstractEdmProvider; -import org.apache.olingo.commons.api.edm.provider.CsdlComplexType; -import org.apache.olingo.commons.api.edm.provider.CsdlEntityContainer; -import org.apache.olingo.commons.api.edm.provider.CsdlEntityContainerInfo; -import org.apache.olingo.commons.api.edm.provider.CsdlEntitySet; -import org.apache.olingo.commons.api.edm.provider.CsdlEntityType; -import org.apache.olingo.commons.api.edm.provider.CsdlNavigationProperty; -import org.apache.olingo.commons.api.edm.provider.CsdlNavigationPropertyBinding; -import org.apache.olingo.commons.api.edm.provider.CsdlProperty; -import org.apache.olingo.commons.api.edm.provider.CsdlPropertyRef; -import org.apache.olingo.commons.api.edm.provider.CsdlSchema; - -public class CarsEdmProvider extends CsdlAbstractEdmProvider { - - // Service Namespace - public static final String NAMESPACE = "olingo.odata.sample"; - - // EDM Container - public static final String CONTAINER_NAME = "Container"; - public static final FullQualifiedName CONTAINER_FQN = new FullQualifiedName(NAMESPACE, CONTAINER_NAME); - - // Entity Types Names - public static final FullQualifiedName ET_CAR = new FullQualifiedName(NAMESPACE, "Car"); - public static final FullQualifiedName ET_MANUFACTURER = new FullQualifiedName(NAMESPACE, "Manufacturer"); - - // Complex Type Names - public static final FullQualifiedName CT_ADDRESS = new FullQualifiedName(NAMESPACE, "Address"); - - // Entity Set Names - public static final String ES_CARS_NAME = "Cars"; - public static final String ES_MANUFACTURER_NAME = "Manufacturers"; - - @Override - public CsdlEntityType getEntityType(final FullQualifiedName entityTypeName) throws ODataException { - if (ET_CAR.equals(entityTypeName)) { - return new CsdlEntityType() - .setName(ET_CAR.getName()) - .setKey(Arrays.asList( - new CsdlPropertyRef().setName("Id"))) - .setProperties( - Arrays.asList( - new CsdlProperty().setName("Id").setType(EdmPrimitiveTypeKind.Int16.getFullQualifiedName()), - new CsdlProperty().setName("Model").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), - new CsdlProperty().setName("ModelYear").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()) - .setMaxLength(4), - new CsdlProperty().setName("Price").setType(EdmPrimitiveTypeKind.Decimal.getFullQualifiedName()) - .setScale(2), - new CsdlProperty().setName("Currency").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()) - .setMaxLength(3) - ) - ).setNavigationProperties(Arrays.asList( - new CsdlNavigationProperty().setName("Manufacturer").setType(ET_MANUFACTURER) - ) - ); - - } else if (ET_MANUFACTURER.equals(entityTypeName)) { - return new CsdlEntityType() - .setName(ET_MANUFACTURER.getName()) - .setKey(Arrays.asList( - new CsdlPropertyRef().setName("Id"))) - .setProperties(Arrays.asList( - new CsdlProperty().setName("Id").setType(EdmPrimitiveTypeKind.Int16.getFullQualifiedName()), - new CsdlProperty().setName("Name").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), - new CsdlProperty().setName("Address").setType(CT_ADDRESS)) - ).setNavigationProperties(Arrays.asList( - new CsdlNavigationProperty().setName("Cars").setType(ET_CAR).setCollection(true) - ) - ); - } - - return null; - } - - public CsdlComplexType getComplexType(final FullQualifiedName complexTypeName) throws ODataException { - if (CT_ADDRESS.equals(complexTypeName)) { - return new CsdlComplexType().setName(CT_ADDRESS.getName()).setProperties(Arrays.asList( - new CsdlProperty().setName("Street").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), - new CsdlProperty().setName("City").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), - new CsdlProperty().setName("ZipCode").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()), - new CsdlProperty().setName("Country").setType(EdmPrimitiveTypeKind.String.getFullQualifiedName()) - )); - } - return null; - } - - @Override - public CsdlEntitySet getEntitySet(final FullQualifiedName entityContainer, final String entitySetName) - throws ODataException { - if (CONTAINER_FQN.equals(entityContainer)) { - if (ES_CARS_NAME.equals(entitySetName)) { - return new CsdlEntitySet() - .setName(ES_CARS_NAME) - .setType(ET_CAR) - .setNavigationPropertyBindings( - Arrays.asList( - new CsdlNavigationPropertyBinding().setPath("Manufacturer").setTarget( - CONTAINER_FQN.getFullQualifiedNameAsString() + "/" + ES_MANUFACTURER_NAME))); - } else if (ES_MANUFACTURER_NAME.equals(entitySetName)) { - return new CsdlEntitySet() - .setName(ES_MANUFACTURER_NAME) - .setType(ET_MANUFACTURER).setNavigationPropertyBindings( - Arrays.asList( - new CsdlNavigationPropertyBinding().setPath("Cars") - .setTarget(CONTAINER_FQN.getFullQualifiedNameAsString() + "/" + ES_CARS_NAME))); - } - } - - return null; - } - - @Override - public List getSchemas() throws ODataException { - List schemas = new ArrayList(); - CsdlSchema schema = new CsdlSchema(); - schema.setNamespace(NAMESPACE); - // EntityTypes - List entityTypes = new ArrayList(); - entityTypes.add(getEntityType(ET_CAR)); - entityTypes.add(getEntityType(ET_MANUFACTURER)); - schema.setEntityTypes(entityTypes); - - // ComplexTypes - List complexTypes = new ArrayList(); - complexTypes.add(getComplexType(CT_ADDRESS)); - schema.setComplexTypes(complexTypes); - - // EntityContainer - schema.setEntityContainer(getEntityContainer()); - schemas.add(schema); - - return schemas; - } - - @Override - public CsdlEntityContainer getEntityContainer() throws ODataException { - CsdlEntityContainer container = new CsdlEntityContainer(); - container.setName(CONTAINER_FQN.getName()); - - // EntitySets - List entitySets = new ArrayList(); - container.setEntitySets(entitySets); - entitySets.add(getEntitySet(CONTAINER_FQN, ES_CARS_NAME)); - entitySets.add(getEntitySet(CONTAINER_FQN, ES_MANUFACTURER_NAME)); - - return container; - } - - @Override - public CsdlEntityContainerInfo getEntityContainerInfo(final FullQualifiedName entityContainerName) - throws ODataException { - if (entityContainerName == null || CONTAINER_FQN.equals(entityContainerName)) { - return new CsdlEntityContainerInfo().setContainerName(CONTAINER_FQN); - } - return null; - } -} diff --git a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/processor/CarsProcessor.java b/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/processor/CarsProcessor.java deleted file mode 100644 index 530bdf79..00000000 --- a/tests/olingo_server/src/main/java/org/apache/olingo/server/sample/processor/CarsProcessor.java +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.olingo.server.sample.processor; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.charset.Charset; -import java.util.List; -import java.util.Locale; - -import org.apache.olingo.commons.api.data.ContextURL; -import org.apache.olingo.commons.api.data.ContextURL.Suffix; -import org.apache.olingo.commons.api.data.Entity; -import org.apache.olingo.commons.api.data.EntityCollection; -import org.apache.olingo.commons.api.data.Property; -import org.apache.olingo.commons.api.edm.EdmComplexType; -import org.apache.olingo.commons.api.edm.EdmEntitySet; -import org.apache.olingo.commons.api.edm.EdmPrimitiveType; -import org.apache.olingo.commons.api.edm.EdmProperty; -import org.apache.olingo.commons.api.format.ContentType; -import org.apache.olingo.commons.api.http.HttpHeader; -import org.apache.olingo.commons.api.http.HttpStatusCode; -import org.apache.olingo.server.api.OData; -import org.apache.olingo.server.api.ODataApplicationException; -import org.apache.olingo.server.api.ODataLibraryException; -import org.apache.olingo.server.api.ODataRequest; -import org.apache.olingo.server.api.ODataResponse; -import org.apache.olingo.server.api.ServiceMetadata; -import org.apache.olingo.server.api.deserializer.DeserializerException; -import org.apache.olingo.server.api.processor.ComplexProcessor; -import org.apache.olingo.server.api.processor.EntityCollectionProcessor; -import org.apache.olingo.server.api.processor.EntityProcessor; -import org.apache.olingo.server.api.processor.PrimitiveProcessor; -import org.apache.olingo.server.api.processor.PrimitiveValueProcessor; -import org.apache.olingo.server.api.serializer.ComplexSerializerOptions; -import org.apache.olingo.server.api.serializer.EntityCollectionSerializerOptions; -import org.apache.olingo.server.api.serializer.EntitySerializerOptions; -import org.apache.olingo.server.api.serializer.ODataSerializer; -import org.apache.olingo.server.api.serializer.PrimitiveSerializerOptions; -import org.apache.olingo.server.api.serializer.SerializerException; -import org.apache.olingo.server.api.uri.UriInfo; -import org.apache.olingo.server.api.uri.UriInfoResource; -import org.apache.olingo.server.api.uri.UriResource; -import org.apache.olingo.server.api.uri.UriResourceEntitySet; -import org.apache.olingo.server.api.uri.UriResourceProperty; -import org.apache.olingo.server.api.uri.queryoption.ExpandOption; -import org.apache.olingo.server.api.uri.queryoption.SelectOption; -import org.apache.olingo.server.sample.data.DataProvider; -import org.apache.olingo.server.sample.data.DataProvider.DataProviderException; - -/** - * This processor will deliver entity collections, single entities as well as properties of an entity. - * This is a very simple example which should give you a rough guideline on how to implement such an processor. - * See the JavaDoc of the server.api interfaces for more information. - */ -public class CarsProcessor implements EntityCollectionProcessor, EntityProcessor, - PrimitiveProcessor, PrimitiveValueProcessor, ComplexProcessor { - - private OData odata; - private final DataProvider dataProvider; - private ServiceMetadata edm; - - // This constructor is application specific and not mandatory for the Olingo library. We use it here to simulate the - // database access - public CarsProcessor(final DataProvider dataProvider) { - this.dataProvider = dataProvider; - } - - @Override - public void init(OData odata, ServiceMetadata edm) { - this.odata = odata; - this.edm = edm; - } - - @Override - public void readEntityCollection(final ODataRequest request, ODataResponse response, final UriInfo uriInfo, - final ContentType requestedContentType) throws ODataApplicationException, SerializerException { - // First we have to figure out which entity set to use - final EdmEntitySet edmEntitySet = getEdmEntitySet(uriInfo.asUriInfoResource()); - - // Second we fetch the data for this specific entity set from the mock database and transform it into an EntitySet - // object which is understood by our serialization - EntityCollection entitySet = dataProvider.readAll(edmEntitySet); - - // Next we create a serializer based on the requested format. This could also be a custom format but we do not - // support them in this example - ODataSerializer serializer = odata.createSerializer(requestedContentType); - - // Now the content is serialized using the serializer. - final ExpandOption expand = uriInfo.getExpandOption(); - final SelectOption select = uriInfo.getSelectOption(); - final String id = request.getRawBaseUri() + "/" + edmEntitySet.getName(); - InputStream serializedContent = serializer.entityCollection(edm, edmEntitySet.getEntityType(), entitySet, - EntityCollectionSerializerOptions.with() - .id(id) - .contextURL(isODataMetadataNone(requestedContentType) ? null : - getContextUrl(edmEntitySet, false, expand, select, null)) - .count(uriInfo.getCountOption()) - .expand(expand).select(select) - .build()).getContent(); - - // Finally we set the response data, headers and status code - response.setContent(serializedContent); - response.setStatusCode(HttpStatusCode.OK.getStatusCode()); - response.setHeader(HttpHeader.CONTENT_TYPE, requestedContentType.toContentTypeString()); - } - - @Override - public void readEntity(final ODataRequest request, ODataResponse response, final UriInfo uriInfo, - final ContentType requestedContentType) throws ODataApplicationException, SerializerException { - // First we have to figure out which entity set the requested entity is in - final EdmEntitySet edmEntitySet = getEdmEntitySet(uriInfo.asUriInfoResource()); - - // Next we fetch the requested entity from the database - Entity entity; - try { - entity = readEntityInternal(uriInfo.asUriInfoResource(), edmEntitySet); - } catch (DataProviderException e) { - throw new ODataApplicationException(e.getMessage(), 500, Locale.ENGLISH); - } - - if (entity == null) { - // If no entity was found for the given key we throw an exception. - throw new ODataApplicationException("No entity found for this key", HttpStatusCode.NOT_FOUND - .getStatusCode(), Locale.ENGLISH); - } else { - // If an entity was found we proceed by serializing it and sending it to the client. - ODataSerializer serializer = odata.createSerializer(requestedContentType); - final ExpandOption expand = uriInfo.getExpandOption(); - final SelectOption select = uriInfo.getSelectOption(); - InputStream serializedContent = serializer.entity(edm, edmEntitySet.getEntityType(), entity, - EntitySerializerOptions.with() - .contextURL(isODataMetadataNone(requestedContentType) ? null : - getContextUrl(edmEntitySet, true, expand, select, null)) - .expand(expand).select(select) - .build()).getContent(); - response.setContent(serializedContent); - response.setStatusCode(HttpStatusCode.OK.getStatusCode()); - response.setHeader(HttpHeader.CONTENT_TYPE, requestedContentType.toContentTypeString()); - } - } - - @Override - public void createEntity(ODataRequest request, ODataResponse response, UriInfo uriInfo, - ContentType requestFormat, ContentType responseFormat) - throws ODataApplicationException, DeserializerException, SerializerException { - throw new ODataApplicationException("Entity create is not supported yet.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - @Override - public void deleteEntity(ODataRequest request, ODataResponse response, UriInfo uriInfo) - throws ODataApplicationException { - throw new ODataApplicationException("Entity delete is not supported yet.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - @Override - public void readPrimitive(ODataRequest request, ODataResponse response, UriInfo uriInfo, ContentType format) - throws ODataApplicationException, SerializerException { - readProperty(response, uriInfo, format, false); - } - - @Override - public void readComplex(ODataRequest request, ODataResponse response, UriInfo uriInfo, ContentType format) - throws ODataApplicationException, SerializerException { - readProperty(response, uriInfo, format, true); - } - - @Override - public void readPrimitiveValue(ODataRequest request, ODataResponse response, UriInfo uriInfo, ContentType format) - throws ODataApplicationException, SerializerException { - // First we have to figure out which entity set the requested entity is in - final EdmEntitySet edmEntitySet = getEdmEntitySet(uriInfo.asUriInfoResource()); - // Next we fetch the requested entity from the database - final Entity entity; - try { - entity = readEntityInternal(uriInfo.asUriInfoResource(), edmEntitySet); - } catch (DataProviderException e) { - throw new ODataApplicationException(e.getMessage(), 500, Locale.ENGLISH); - } - if (entity == null) { - // If no entity was found for the given key we throw an exception. - throw new ODataApplicationException("No entity found for this key", HttpStatusCode.NOT_FOUND - .getStatusCode(), Locale.ENGLISH); - } else { - // Next we get the property value from the entity and pass the value to serialization - UriResourceProperty uriProperty = (UriResourceProperty) uriInfo - .getUriResourceParts().get(uriInfo.getUriResourceParts().size() - 1); - EdmProperty edmProperty = uriProperty.getProperty(); - Property property = entity.getProperty(edmProperty.getName()); - if (property == null) { - throw new ODataApplicationException("No property found", HttpStatusCode.NOT_FOUND - .getStatusCode(), Locale.ENGLISH); - } else { - if (property.getValue() == null) { - response.setStatusCode(HttpStatusCode.NO_CONTENT.getStatusCode()); - } else { - String value = String.valueOf(property.getValue()); - ByteArrayInputStream serializerContent = new ByteArrayInputStream( - value.getBytes(Charset.forName("UTF-8"))); - response.setContent(serializerContent); - response.setStatusCode(HttpStatusCode.OK.getStatusCode()); - response.setHeader(HttpHeader.CONTENT_TYPE, ContentType.TEXT_PLAIN.toContentTypeString()); - } - } - } - } - - private void readProperty(ODataResponse response, UriInfo uriInfo, ContentType contentType, - boolean complex) throws ODataApplicationException, SerializerException { - // To read a property we have to first get the entity out of the entity set - final EdmEntitySet edmEntitySet = getEdmEntitySet(uriInfo.asUriInfoResource()); - Entity entity; - try { - entity = readEntityInternal(uriInfo.asUriInfoResource(), edmEntitySet); - } catch (DataProviderException e) { - throw new ODataApplicationException(e.getMessage(), - HttpStatusCode.INTERNAL_SERVER_ERROR.getStatusCode(), Locale.ENGLISH); - } - - if (entity == null) { - // If no entity was found for the given key we throw an exception. - throw new ODataApplicationException("No entity found for this key", - HttpStatusCode.NOT_FOUND.getStatusCode(), Locale.ENGLISH); - } else { - // Next we get the property value from the entity and pass the value to serialization - UriResourceProperty uriProperty = (UriResourceProperty) uriInfo - .getUriResourceParts().get(uriInfo.getUriResourceParts().size() - 1); - EdmProperty edmProperty = uriProperty.getProperty(); - Property property = entity.getProperty(edmProperty.getName()); - if (property == null) { - throw new ODataApplicationException("No property found", - HttpStatusCode.NOT_FOUND.getStatusCode(), Locale.ENGLISH); - } else { - if (property.getValue() == null) { - response.setStatusCode(HttpStatusCode.NO_CONTENT.getStatusCode()); - } else { - ODataSerializer serializer = odata.createSerializer(contentType); - final ContextURL contextURL = isODataMetadataNone(contentType) ? null : - getContextUrl(edmEntitySet, true, null, null, edmProperty.getName()); - InputStream serializerContent = complex ? - serializer.complex(edm, (EdmComplexType) edmProperty.getType(), property, - ComplexSerializerOptions.with().contextURL(contextURL).build()).getContent() : - serializer.primitive(edm, (EdmPrimitiveType) edmProperty.getType(), property, - PrimitiveSerializerOptions.with() - .contextURL(contextURL) - .scale(edmProperty.getScale()) - .nullable(edmProperty.isNullable()) - .precision(edmProperty.getPrecision()) - .maxLength(edmProperty.getMaxLength()) - .unicode(edmProperty.isUnicode()).build()).getContent(); - response.setContent(serializerContent); - response.setStatusCode(HttpStatusCode.OK.getStatusCode()); - response.setHeader(HttpHeader.CONTENT_TYPE, contentType.toContentTypeString()); - } - } - } - } - - private Entity readEntityInternal(final UriInfoResource uriInfo, final EdmEntitySet entitySet) - throws DataProvider.DataProviderException { - // This method will extract the key values and pass them to the data provider - final UriResourceEntitySet resourceEntitySet = (UriResourceEntitySet) uriInfo.getUriResourceParts().get(0); - return dataProvider.read(entitySet, resourceEntitySet.getKeyPredicates()); - } - - private EdmEntitySet getEdmEntitySet(final UriInfoResource uriInfo) throws ODataApplicationException { - final List resourcePaths = uriInfo.getUriResourceParts(); - /* - * To get the entity set we have to interpret all URI segments - */ - if (!(resourcePaths.get(0) instanceof UriResourceEntitySet)) { - throw new ODataApplicationException("Invalid resource type for first segment.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - /* - * Here we should interpret the whole URI but in this example we do not support navigation so we throw an exception - */ - - final UriResourceEntitySet uriResource = (UriResourceEntitySet) resourcePaths.get(0); - return uriResource.getEntitySet(); - } - - private ContextURL getContextUrl(final EdmEntitySet entitySet, final boolean isSingleEntity, - final ExpandOption expand, final SelectOption select, final String navOrPropertyPath) - throws SerializerException { - - return ContextURL.with().entitySet(entitySet) - .selectList(odata.createUriHelper().buildContextURLSelectList(entitySet.getEntityType(), expand, select)) - .suffix(isSingleEntity ? Suffix.ENTITY : null) - .navOrPropertyPath(navOrPropertyPath) - .build(); - } - - @Override - public void updatePrimitive(final ODataRequest request, final ODataResponse response, - final UriInfo uriInfo, final ContentType requestFormat, - final ContentType responseFormat) - throws ODataApplicationException, DeserializerException, SerializerException { - throw new ODataApplicationException("Primitive property update is not supported yet.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - @Override - public void updatePrimitiveValue(final ODataRequest request, ODataResponse response, - final UriInfo uriInfo, final ContentType requestFormat, final ContentType responseFormat) - throws ODataApplicationException, ODataLibraryException { - throw new ODataApplicationException("Primitive property update is not supported yet.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - @Override - public void deletePrimitive(ODataRequest request, ODataResponse response, UriInfo uriInfo) throws - ODataApplicationException { - throw new ODataApplicationException("Primitive property delete is not supported yet.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - @Override - public void deletePrimitiveValue(final ODataRequest request, ODataResponse response, final UriInfo uriInfo) - throws ODataApplicationException, ODataLibraryException { - throw new ODataApplicationException("Primitive property update is not supported yet.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - @Override - public void updateComplex(final ODataRequest request, final ODataResponse response, - final UriInfo uriInfo, final ContentType requestFormat, - final ContentType responseFormat) - throws ODataApplicationException, DeserializerException, SerializerException { - throw new ODataApplicationException("Complex property update is not supported yet.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - @Override - public void deleteComplex(final ODataRequest request, final ODataResponse response, final UriInfo uriInfo) - throws ODataApplicationException { - throw new ODataApplicationException("Complex property delete is not supported yet.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - @Override - public void updateEntity(final ODataRequest request, final ODataResponse response, - final UriInfo uriInfo, final ContentType requestFormat, - final ContentType responseFormat) - throws ODataApplicationException, DeserializerException, SerializerException { - throw new ODataApplicationException("Entity update is not supported yet.", - HttpStatusCode.NOT_IMPLEMENTED.getStatusCode(), Locale.ENGLISH); - } - - public static boolean isODataMetadataNone(final ContentType contentType) { - return contentType.isCompatible(ContentType.APPLICATION_JSON) - && ContentType.VALUE_ODATA_METADATA_NONE.equalsIgnoreCase( - contentType.getParameter(ContentType.PARAMETER_ODATA_METADATA)); - } -} \ No newline at end of file diff --git a/tests/olingo_server/src/main/resources/META-INF/LICENSE b/tests/olingo_server/src/main/resources/META-INF/LICENSE deleted file mode 100644 index 715ff307..00000000 --- a/tests/olingo_server/src/main/resources/META-INF/LICENSE +++ /dev/null @@ -1,331 +0,0 @@ -Licenses for TecSvc artifact - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - - -From: 'abego Software GmbH, Germany' (http://abego-software.de) - abego -TreeLayout Core (http://code.google.com/p/treelayout/) -org.abego.treelayout:org.abego.treelayout.core:jar:1.0.1 License: BSD 3-Clause -"New" or "Revised" License (BSD-3-Clause) -(http://treelayout.googlecode.com/files/LICENSE.TXT) - -[The "BSD license"] -Copyright (c) 2011, abego Software GmbH, Germany (http://www.abego.org) -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. -3. Neither the name of the abego Software GmbH nor the names of its - contributors may be used to endorse or promote products derived from this - software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. - - -From: 'ANTLR' (http://www.antlr.org) - ANTLR 4 Runtime -(http://www.antlr.org/antlr4-runtime) org.antlr:antlr4-runtime:jar:4.1 License: -The BSD License (http://www.antlr.org/license.html) - -[The BSD License] -Copyright (c) 2012 Terence Parr and Sam Harwell -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. Redistributions in binary - form must reproduce the above copyright notice, this list of conditions and - the following disclaimer in the documentation and/or other materials - provided with the distribution. Neither the name of the author nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -From: 'fasterxml.com' (http://fasterxml.com) - Stax2 API -(http://wiki.fasterxml.com/WoodstoxStax2) -org.codehaus.woodstox:stax2-api:bundle:3.1.4 License: The BSD License -(http://www.opensource.org/licenses/bsd-license.php) - -Copyright (c) 2004-2010, Woodstox Project (http://woodstox.codehaus.org/) -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. -3. Neither the name of the Woodstox XML Processor nor the names - of its contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. - - -From: 'QOS.ch' (http://www.qos.ch) - - SLF4J API Module (http://www.slf4j.org) org.slf4j:slf4j-api:jar:1.7.7 - License: MIT License (http://www.opensource.org/licenses/mit-license.php) - - SLF4J Simple Binding (http://www.slf4j.org) org.slf4j:slf4j-simple:jar:1.7.7 - License: MIT License (http://www.opensource.org/licenses/mit-license.php) - - -Copyright (c) 2004-2013 QOS.ch - -All rights reserved. Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated documentation files -(the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, -sublicense, and/or sell copies of the Software, and to permit persons to whom -the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/tests/olingo_server/src/main/resources/simplelogger.properties b/tests/olingo_server/src/main/resources/simplelogger.properties deleted file mode 100644 index 2a3350c7..00000000 --- a/tests/olingo_server/src/main/resources/simplelogger.properties +++ /dev/null @@ -1,20 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -org.slf4j.simpleLogger.defaultLogLevel=debug -org.slf4j.simpleLogger.logFile=System.out \ No newline at end of file diff --git a/tests/olingo_server/src/main/version/version.html b/tests/olingo_server/src/main/version/version.html deleted file mode 100644 index 7bc2ddd9..00000000 --- a/tests/olingo_server/src/main/version/version.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - -
Version Information
HomeApache Olingo
name${name}
version${version}
timestamp${timestamp}
diff --git a/tests/olingo_server/src/main/webapp/WEB-INF/web.xml b/tests/olingo_server/src/main/webapp/WEB-INF/web.xml deleted file mode 100644 index 2a263678..00000000 --- a/tests/olingo_server/src/main/webapp/WEB-INF/web.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - Apache Olingo OData 4.0 Sample Service - - - index.jsp - - - - CarsServlet - org.apache.olingo.server.sample.CarsServlet - 1 - - - - CarsServlet - /cars.svc/* - - - diff --git a/tests/olingo_server/src/main/webapp/css/olingo.css b/tests/olingo_server/src/main/webapp/css/olingo.css deleted file mode 100644 index 5b9deec0..00000000 --- a/tests/olingo_server/src/main/webapp/css/olingo.css +++ /dev/null @@ -1,91 +0,0 @@ -/* - Licensed to the Apache Software Foundation (ASF) under one - or more contributor license agreements. See the NOTICE file - distributed with this work for additional information - regarding copyright ownership. The ASF licenses this file - to you under the Apache License, Version 2.0 (the - "License"); you may not use this file except in compliance - with the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, - software distributed under the License is distributed on an - "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - KIND, either express or implied. See the License for the - specific language governing permissions and limitations - under the License. -*/ -body { - font-family: Arial, sans-serif; - font-size: 13px; - line-height: 18px; - color: #8904B1; - background-color: #ffffff; -} - -a { - color: #8904B1; -} - -a:VISITED { - color: #D358F7; -} - -td { - padding: 5px; -} - -h1,h2,h3,h4,h5,h6 { - font-family: inherit; - font-weight: bold; - line-height: 1; - color: #8904B1; -} - -h1 { - font-size: 36px; - line-height: 40px; -} - -h2 { - font-size: 30px; - line-height: 40px; -} - -h3 { - font-size: 24px; - line-height: 40px; -} - -h4 { - font-size: 18px; - line-height: 20px; -} - -h5 { - font-size: 14px; - line-height: 20px; -} - -h6 { - font-size: 12px; - line-height: 20px; -} - -.logo { - float: right; -} - -hr, thead, tfoot { - margin: 9px 0; - border-top: 1px solid #8904B1; - border-bottom: 1px solid #8904B1; -} - -table { border-collapse: collapse; border: 1px solid #8904B1; } - -.version { - font-family: "Courier New", monospace; - font-size: 10px; -} \ No newline at end of file diff --git a/tests/olingo_server/src/main/webapp/img/OlingoOrangeTM.png b/tests/olingo_server/src/main/webapp/img/OlingoOrangeTM.png deleted file mode 100644 index 4878e8a5c258ae1c1dc48f5f10a4268e63b11756..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 113360 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 z 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 l4l(!&7fD4BDjE+v$*n&XV(>iU=#u37Izu> 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 Um`B3+@#Fgv6OtCJ=GXQAKeK)?8UO$Q diff --git a/tests/olingo_server/src/main/webapp/index.jsp b/tests/olingo_server/src/main/webapp/index.jsp deleted file mode 100644 index 2940a76f..00000000 --- a/tests/olingo_server/src/main/webapp/index.jsp +++ /dev/null @@ -1,56 +0,0 @@ -<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> - - - - - - - - Apache Olingo - OData 4.0 - - - - -

-

Olingo OData 4.0

-
-

Cars Sample Service

-
-
-
- <% String version = "gen/version.html"; - try { - %> - - <%} catch (Exception e) { - %> -

IDE Build

- <%}%> -
- - diff --git a/tests/v2/test_service.py b/tests/v2/test_service.py index 2acf30f8..67035761 100644 --- a/tests/v2/test_service.py +++ b/tests/v2/test_service.py @@ -6,6 +6,9 @@ import pytest from unittest.mock import patch +# from typeguard.importhook import install_import_hook +# install_import_hook('pyodata.v2') + import pyodata.v2.service from pyodata.exceptions import PyODataException, HttpError, ExpressionError, PyODataModelError from pyodata.v2.service import EntityKey, EntityProxy, GetEntitySetFilter diff --git a/tests/v4/test_service.py b/tests/v4/test_service.py new file mode 100644 index 00000000..fdb10ed8 --- /dev/null +++ b/tests/v4/test_service.py @@ -0,0 +1,93 @@ +import logging + +import logging + +# logging.basicConfig() +# +# root_logger = logging.getLogger() +# root_logger.setLevel(logging.DEBUG) + +import pytest +import requests + +from pyodata.model.elements import Types +from pyodata.exceptions import PyODataException +from pyodata.client import Client +from pyodata.config import Config +from pyodata.v4 import ODataV4 + +URL_ROOT = 'http://localhost:8888/odata/4/Default.scv/' + + +@pytest.fixture +def service(): + """Service fixture""" + # metadata = _fetch_metadata(requests.Session(), URL_ROOT) + # config = Config(ODataV4) + # schema = MetadataBuilder(metadata, config=config).build() + # + # return Service(URL_ROOT, schema, requests.Session()) + + # typ = Types.from_name('Collection(Edm.Int32)', Config(ODataV4)) + # t = typ.traits.from_json(['23', '34']) + # assert typ.traits.from_json(['23', '34']) == [23, 34] + + return Client(URL_ROOT, requests.Session(), config=Config(ODataV4)) + + +@pytest.fixture +def airport_entity(): + return { + 'Name': 'Dawn Summit 2', + 'Location': { + 'Address': 'Gloria', + 'City': 'West' + }} + + +# def test_create_entity(service, airport_entity): +# """Basic test on creating entity""" +# +# result = service.entity_sets.Airports.create_entity().set(**airport_entity).execute() +# assert result.Name == 'Dawn Summit 2' +# assert result.Location['Address'] == 'Gloria' +# assert result.Location['City'] == 'West' +# assert isinstance(result.Id, int) +# +# +# def test_create_entity_code_400(service, airport_entity): +# """Test that exception is raised in case of incorrect return code""" +# +# del airport_entity['Name'] +# with pytest.raises(PyODataException) as e_info: +# service.entity_sets.Airports.create_entity().set(**airport_entity).execute() +# +# assert str(e_info.value).startswith('HTTP POST for Entity Set') +# +# +# def test_create_entity_nested(service): +# """Basic test on creating entity""" +# +# # pylint: disable=redefined-outer-name +# +# entity = { +# 'Emails': [ +# 'christopher32@hotmail.com', +# 'danielwarner@wallace.biz' +# ], +# 'AddressInfo': [{ +# 'Address': '8561 Ruth Course\\nTonyton, MA 75643', +# 'City': 'North Kristenport' +# }], +# 'Gender': 'Male', +# 'UserName': 'Kenneth Allen', +# 'Pictures': [{ +# 'Name': 'wish.jpg' +# }] +# } +# +# result = service.entity_sets.Persons.create_entity().set(**entity).execute() +# +# pass + # assert result.Name == 'Hadraplan' + # assert result.nav('IDPic').get_value().execute().content == b'DEADBEEF' From f160c93c0773d4578b30497eefc86858a1f6f238 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 10 Apr 2020 12:48:49 +0200 Subject: [PATCH 26/36] More types! --- pyodata/model/elements.py | 8 ++++---- pyodata/v2/service.py | 36 ++++++++++++++++++++---------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py index 64f30b17..1a6dc0f8 100644 --- a/pyodata/model/elements.py +++ b/pyodata/model/elements.py @@ -5,7 +5,7 @@ import logging from abc import abstractmethod from enum import Enum -from typing import Union +from typing import Union, Dict from pyodata.policies import ParserError from pyodata.config import Config @@ -104,7 +104,7 @@ def __getattr__(self, item): class Identifier: - def __init__(self, name): + def __init__(self, name: str): super(Identifier, self).__init__() self._name = name @@ -638,7 +638,7 @@ def __init__(self, name, label, is_value_list): self._label = label self._is_value_list = is_value_list self._key = list() - self._properties = dict() + self._properties: Dict[str, 'StructTypeProperty'] = dict() @property def label(self): @@ -648,7 +648,7 @@ def label(self): def is_value_list(self): return self._is_value_list - def proprty(self, property_name): + def proprty(self, property_name: str) -> 'StructTypeProperty': try: return self._properties[property_name] except KeyError: diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index 50c4f327..a93e9871 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -793,7 +793,11 @@ def __init__(self, service: 'Service', entity_set: Union[EntitySet, 'EntitySetPr pass def __repr__(self) -> str: - return self._entity_key.to_key_string() + entity_key = self._entity_key + if entity_key is None: + raise PyODataException('Entity key is None') + + return entity_key.to_key_string() def __getattr__(self, attr: str) -> Any: try: @@ -844,8 +848,7 @@ def nav(self, nav_property: str) -> Union['NavEntityProxy', 'EntitySetProxy']: def get_path(self) -> str: """Returns this entity's relative path - e.g. EntitySet(KEY)""" - - return self._entity_set._name + self._entity_key.to_key_string() # pylint: disable=protected-access + return str(self._entity_set._name + self._entity_key.to_key_string()) # pylint: disable=protected-access def get_proprty(self, name: str, connection: Optional[requests.Session] = None) -> ODataHttpRequest: """Returns value of the property""" @@ -886,7 +889,7 @@ def value_get_handler(key: Any, response: requests.Response) -> requests.Respons connection=connection) @property - def entity_set(self) -> EntitySet: + def entity_set(self) -> Optional[Union['EntitySet', 'EntitySetProxy']]: """Entity set related to this entity""" return self._entity_set @@ -906,7 +909,7 @@ def url(self) -> str: return urljoin(service_url, entity_path) - def equals(self, other) -> bool: + def equals(self, other: 'EntityProxy') -> bool: """Returns true if the self and the other contains the same data""" # pylint: disable=W0212 return self._cache == other._cache @@ -1057,7 +1060,8 @@ def _get_nav_entity(self, master_key: EntityKey, nav_property: str, navigation_entity_set: EntitySet) -> NavEntityGetRequest: """Get entity based on provided key of the master and Navigation property name""" - def get_entity_handler(parent, nav_property, navigation_entity_set, response) -> NavEntityProxy: + def get_entity_handler(parent: EntityProxy, nav_property: str, navigation_entity_set: EntitySet, + response: requests.Response) -> NavEntityProxy: """Gets entity from HTTP response""" if response.status_code != requests.codes.ok: @@ -1082,10 +1086,10 @@ def get_entity_handler(parent, nav_property, navigation_entity_set, response) -> self, nav_property) - def get_entity(self, key=None, **args): + def get_entity(self, key=None, **args) -> EntityGetRequest: """Get entity based on provided key properties""" - def get_entity_handler(response): + def get_entity_handler(response: requests.Response) -> EntityProxy: """Gets entity from HTTP response""" if response.status_code != requests.codes.ok: @@ -1108,7 +1112,7 @@ def get_entity_handler(response): def get_entities(self): """Get all entities""" - def get_entities_handler(response): + def get_entities_handler(response: requests.Response) -> Union[List[EntityProxy], int]: """Gets entity set from HTTP Response""" if response.status_code != requests.codes.ok: @@ -1133,10 +1137,10 @@ def get_entities_handler(response): return GetEntitySetRequest(self._service.url, self._service.connection, get_entities_handler, self._parent_last_segment + entity_set_name, self._entity_set.entity_type) - def create_entity(self, return_code=requests.codes.created): + def create_entity(self, return_code: int = requests.codes.created) -> EntityCreateRequest: """Creates a new entity in the given entity-set.""" - def create_entity_handler(response): + def create_entity_handler(response: requests.Response) -> EntityProxy: """Gets newly created entity encoded in HTTP Response""" if response.status_code != return_code: @@ -1150,10 +1154,10 @@ def create_entity_handler(response): return EntityCreateRequest(self._service.url, self._service.connection, create_entity_handler, self._entity_set, self.last_segment) - def update_entity(self, key=None, **kwargs): + def update_entity(self, key=None, **kwargs) -> EntityModifyRequest: """Updates an existing entity in the given entity-set.""" - def update_entity_handler(response): + def update_entity_handler(response: requests.Response) -> None: """Gets modified entity encoded in HTTP Response""" if response.status_code != 204: @@ -1343,7 +1347,7 @@ def http_get(self, path: str, connection: Optional[requests.Session] = None) -> return conn.get(urljoin(self._url, path)) def http_get_odata(self, path: str, handler: Callable[[requests.Response], Any], - connection: Optional[requests.Session] = None): + connection: Optional[requests.Session] = None) -> ODataHttpRequest: """HTTP GET request proxy for the passed path in the service""" conn = connection @@ -1364,7 +1368,7 @@ def batch_handler(batch: MultipartRequest, parts: List[List[str]]) -> List[Any]: logging.getLogger(LOGGER_NAME).debug('Batch handler called for batch %s', batch.id) - result = [] + result: List[Any] = [] for part, req in zip(parts, batch.requests): logging.getLogger(LOGGER_NAME).debug('Batch handler is processing part %s for request %s', part, req) @@ -1384,7 +1388,7 @@ def batch_handler(batch: MultipartRequest, parts: List[List[str]]) -> List[Any]: def create_changeset(self, changeset_id=None): """Create instance of OData changeset""" - def changeset_handler(changeset: 'Changeset', parts: List[str]): + def changeset_handler(changeset: 'Changeset', parts: List[str]) -> List[ODataHttpResponse]: """Gets changeset response from HTTP response""" logging.getLogger(LOGGER_NAME).debug('Changeset handler called for changeset %s', changeset.id) From 17f4834db87ba967565a640abf4dbbd71db1a491 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 24 Apr 2020 16:27:43 +0200 Subject: [PATCH 27/36] Add basic layout of design documentation --- deisgn-doc/file-hierarchy.png | Bin 0 -> 136031 bytes deisgn-doc/pyodata-source.md | 119 ++++++++++++++++++++++++++++++++++ deisgn-doc/pyodata-tests.md | 5 ++ 3 files changed, 124 insertions(+) create mode 100644 deisgn-doc/file-hierarchy.png create mode 100644 deisgn-doc/pyodata-source.md create mode 100644 deisgn-doc/pyodata-tests.md diff --git a/deisgn-doc/file-hierarchy.png b/deisgn-doc/file-hierarchy.png new file mode 100644 index 0000000000000000000000000000000000000000..44131c13642d3bfb8909e89473b5703ffcca08c4 GIT binary patch literal 136031 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%0A>pJ3KeqhTo@UoCRPDc$i^!PlF_<779 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 literal 0 HcmV?d00001 diff --git a/deisgn-doc/pyodata-source.md b/deisgn-doc/pyodata-source.md new file mode 100644 index 00000000..8cab27be --- /dev/null +++ b/deisgn-doc/pyodata-source.md @@ -0,0 +1,119 @@ + +# Table of content + +1. [Code separation into multiple files](#Structure) +2. [Defining OData version in the code](#version-specific-code) +3. [Working with metadata and model](#Model) + +## Code separation into multiple files +The codebase is now split into logical units. This is in contrast to the single-file approach in previous releases. +Reasoning behind that is to make code more readable, easier to understand but mainly to allow modularity for different +OData versions. + +Root source folder, _pyodata/_, contains files that are to be used in all other parts of the library +(e. g. config.py, exceptions.py). Folder Model contains code for parsing the OData Metadata, whereas folder Service +contains code for consuming the OData Service. Both folders are to be used purely for OData version-independent code. +Version dependent belongs to folders v2, v3, v4, respectively. + +![New file hierarchy in one picture](file-hierarchy.png) + +## Handling OData version specific code +Class Version defines the interface for working with different OData versions. Each definition should be the same +throughout out the runtime, hence all methods are static and children itself can not be instantiated. Most +important are these methods: +- `primitive_types() -> List['Typ']` is a method, which returns a list of supported primitive types in given version +- `build_functions() -> Dict[type, Callable]:` is a methods, which returns a dictionary where, Elements classes are +used as keys and build functions are used as values. +- `annotations() -> Dict['Annotation', Callable]:` is a methods, which returns a dictionary where, Annotations classes +are used as keys and build functions are used as values. + +The last two methods are the core change of this release. They allow us to link elements classes with different build +functions in each version of OData. + +Note the type of dictionary key for builder functions. It is not a string representation of the class name but is +rather type of the class itself. That helps us avoid magical string in the code. + +Also, note that because of this design all elements which are to be used by the end-user are imported here. +Thus, the API for end-user is simplified as he/she should only import code which is directly exposed by this module +(e. g. pyodata.v2.XXXElement...). + +```python +class ODataVX(ODATAVersion): + @staticmethod + def build_functions(): + return { + ... + StructTypeProperty: build_struct_type_property, + NavigationTypeProperty: build_navigation_type_property, + ... + } + + @staticmethod + def primitive_types() -> List[Typ]: + return [ + ... + Typ('Null', 'null'), + Typ('Edm.Binary', '', EdmDoubleQuotesEncapsulatedTypTraits()), + Typ('Edm.Boolean', 'false', EdmBooleanTypTraits()), + ... + ] + + @staticmethod + def annotations(): + return { Unit: build_unit_annotation } +``` + + +### Version definition location +Class defining specific should be located in the `__init__.py` file in the directory, which encapsulates the rest of +appropriate version-specific code. + +## Working with metadata and model +Code in the model is further separated into logical units. If any version-specific code is to be +added into appropriate folders, it must shadow the file structure declared in the model. + +- *elements.py* contains the python representation of EDM elements(e. g. Schema, StructType...) +- *type_taraits.py* contains classes describing conversions between python and JSON/XML representation of data +- *builder.py* contains single class MetadataBuilder, which purpose is to parse the XML using lxml, +check is namespaces are valid and lastly call build Schema and return the result. +- *build_functions.py* contains code which transforms XML code into appropriate python representation. More on that in +the next paragraph. + +### Build functions +Build functions receive EDM element as etree nodes and return Python instance of a given element. In the previous release +they were implemented as from_etree methods directly in the element class, but that presented a problem as the elements +could not be reused among different versions of OData as the XML representation can vary widely. All functions are +prefixed with build_ followed by the element class name (e. g. `build_struct_type_property`). + +Every function must return the element instance or raise an exception. In a case, that exception is raised and appropriate +policy is set to non-fatal function must return dummy element instance(NullType). One exception to build a function that +do not return element are annotations builders; as annotations are not self-contained elements but rather +descriptors to existing ones. + +```python +def build_entity_type(config: Config, type_node, schema=None): + try: + etype = build_element(StructType, config, type_node=type_node, typ=EntityType, schema=schema) + + for proprty in type_node.xpath('edm:Key/edm:PropertyRef', namespaces=config.namespaces): + etype._key.append(etype.proprty(proprty.get('Name'))) + + ... + + return etype + except (PyODataParserError, AttributeError) as ex: + config.err_policy(ParserError.ENTITY_TYPE).resolve(ex) + return NullType(type_node.get('Name')) +``` + +### Building an element from metadata +In the file model/elements.py, there is helper function build_element, which makes it easier to build element; +rather than manually checking the OData version and then searching build_functions dictionary, we can pass the class type, +config instance and lastly kwargs(etree node, schema etc...). The function then will call appropriate build function +based on OData version declared in config witch the config and kwargs as arguments and then return the result. +```Python +build_element(EntitySet, config, entity_set_node=entity_set) +``` + + +// Author note: Should be StrucType removed from the definition of build_functions? diff --git a/deisgn-doc/pyodata-tests.md b/deisgn-doc/pyodata-tests.md new file mode 100644 index 00000000..4885e145 --- /dev/null +++ b/deisgn-doc/pyodata-tests.md @@ -0,0 +1,5 @@ +# Table of content + +1. [Structure of tests](#Structure) +2. [Testing build functions](#) +3. [Testing policies](#) \ No newline at end of file From 1d0193d6b623a0d70b6d085d988d5667ae8d8f8d Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 24 Apr 2020 17:43:58 +0200 Subject: [PATCH 28/36] More documentation --- deisgn-doc/Changes-in-tests.md | 51 +++++++++++++++++++ deisgn-doc/{pyodata-source.md => Overview.md} | 5 ++ deisgn-doc/TOC.md | 2 + deisgn-doc/pyodata-tests.md | 5 -- 4 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 deisgn-doc/Changes-in-tests.md rename deisgn-doc/{pyodata-source.md => Overview.md} (94%) create mode 100644 deisgn-doc/TOC.md delete mode 100644 deisgn-doc/pyodata-tests.md diff --git a/deisgn-doc/Changes-in-tests.md b/deisgn-doc/Changes-in-tests.md new file mode 100644 index 00000000..ae9711d9 --- /dev/null +++ b/deisgn-doc/Changes-in-tests.md @@ -0,0 +1,51 @@ +# Table of content + +1. [Tests file structure](#Structure) +2. [Test and classes](#Clases) +3. [Using metadata templates](#Templates) + + +## Tests file structure +The tests are split into multiple files: test_build_functions, test_build_functions_with_policies, +test_elements, test_type_traits. The diference between test_build_functions and test_build_functions_with_policies is +that the later is for testing on invalid metadata. + + +## Test and classes +In previos versions all tests were writen as standalone function, however this is makes hard to orientate in the code +and it makes hard to kwno witch tests are related. Tests in this release are bundled together in appropriate places. +Such as when testing build_function(see the example below). Another advantage of bundeling is that tests for specific +bundles can be run separately. + +```python +class TestSchema: + def test_types(self, schema): + assert isinstance(schema.complex_type('Location'), ComplexType) + ... + assert isinstance(schema.entity_set('People'), EntitySet) + + def test_property_type(self, schema): + person = schema.entity_type('Person') + ... + assert repr(person.proprty('Weight').typ) == 'Typ(Weight)' + assert repr(person.proprty('AddressInfo').typ) == 'Collection(ComplexType(Location))' + ... +``` + +## Using metadata templates +For testing the V4 there are two sets of metadata. `tests/v4/metadata.xml` is filed with test entities, types, sets etc. +while the `tests/v4/metadata.template.xml` is only metadata skeleton. The latter is useful when there is need for +ceranty that any other metadata arent influensing the result, when custom elements are needed for specific test or when +you are working with invalid metadata. + +To use the metadata template the Ninja2 is requited. Ninja2 is template engine which can load up the template xml and +fill it with provided data. Fixture template_builder is available to all tests. Calling the fixture with array of EMD +elements will return MetadataBuilder already filled with your custom data. + +```python + faulty_entity = """ + + + """ + builder, config = template_builder(ODataV4, schema_elements=[faulty_entity]) +``` \ No newline at end of file diff --git a/deisgn-doc/pyodata-source.md b/deisgn-doc/Overview.md similarity index 94% rename from deisgn-doc/pyodata-source.md rename to deisgn-doc/Overview.md index 8cab27be..309d00b3 100644 --- a/deisgn-doc/pyodata-source.md +++ b/deisgn-doc/Overview.md @@ -4,6 +4,7 @@ 1. [Code separation into multiple files](#Structure) 2. [Defining OData version in the code](#version-specific-code) 3. [Working with metadata and model](#Model) +4. [GeoJson optional depencency](#GeoJson) ## Code separation into multiple files The codebase is now split into logical units. This is in contrast to the single-file approach in previous releases. @@ -115,5 +116,9 @@ based on OData version declared in config witch the config and kwargs as argumen build_element(EntitySet, config, entity_set_node=entity_set) ``` +## GeoJson optional depencency +OData V4 introduced support for multiple standardized geolocation types. To use them GeoJson depencency is required, but +as it is likely that not everyone will use these types the depencency is optional and stored in requirments-optional.txt + // Author note: Should be StrucType removed from the definition of build_functions? diff --git a/deisgn-doc/TOC.md b/deisgn-doc/TOC.md new file mode 100644 index 00000000..6a29faf5 --- /dev/null +++ b/deisgn-doc/TOC.md @@ -0,0 +1,2 @@ +- [Changes Overview](Overview.md) +- [Changes in Tests](Changes-in-tests.md) \ No newline at end of file diff --git a/deisgn-doc/pyodata-tests.md b/deisgn-doc/pyodata-tests.md deleted file mode 100644 index 4885e145..00000000 --- a/deisgn-doc/pyodata-tests.md +++ /dev/null @@ -1,5 +0,0 @@ -# Table of content - -1. [Structure of tests](#Structure) -2. [Testing build functions](#) -3. [Testing policies](#) \ No newline at end of file From bdd010c5acb4bd12064998b03b426524255b1969 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Fri, 1 May 2020 15:04:25 +0200 Subject: [PATCH 29/36] Documentation add annotation and fix typos --- .../{Overview.md => Changes-in-pyodata.md} | 7 +++++++ deisgn-doc/Changes-in-tests.md | 20 +++++++++---------- deisgn-doc/TOC.md | 2 +- 3 files changed, 18 insertions(+), 11 deletions(-) rename deisgn-doc/{Overview.md => Changes-in-pyodata.md} (92%) diff --git a/deisgn-doc/Overview.md b/deisgn-doc/Changes-in-pyodata.md similarity index 92% rename from deisgn-doc/Overview.md rename to deisgn-doc/Changes-in-pyodata.md index 309d00b3..aac81e64 100644 --- a/deisgn-doc/Overview.md +++ b/deisgn-doc/Changes-in-pyodata.md @@ -4,6 +4,7 @@ 1. [Code separation into multiple files](#Structure) 2. [Defining OData version in the code](#version-specific-code) 3. [Working with metadata and model](#Model) +3. [Annotations](#Annotations) 4. [GeoJson optional depencency](#GeoJson) ## Code separation into multiple files @@ -116,6 +117,12 @@ based on OData version declared in config witch the config and kwargs as argumen build_element(EntitySet, config, entity_set_node=entity_set) ``` +## Annotations +Annotations are handle bit diferently to the rest of EDM elements. That is due to that, annocation do not represent +standalone elements/instances in resulting Model. Annotations are procesed by build_anotation, build functio expect +_target_(an element to annotate) and _Annotation Term_(Name of the annotatio), build_annotation does not return any value. +Anotation term is searched in annotations dictionary defined in the OData version subclass. + ## GeoJson optional depencency OData V4 introduced support for multiple standardized geolocation types. To use them GeoJson depencency is required, but as it is likely that not everyone will use these types the depencency is optional and stored in requirments-optional.txt diff --git a/deisgn-doc/Changes-in-tests.md b/deisgn-doc/Changes-in-tests.md index ae9711d9..ef4cba8d 100644 --- a/deisgn-doc/Changes-in-tests.md +++ b/deisgn-doc/Changes-in-tests.md @@ -12,10 +12,10 @@ that the later is for testing on invalid metadata. ## Test and classes -In previos versions all tests were writen as standalone function, however this is makes hard to orientate in the code -and it makes hard to kwno witch tests are related. Tests in this release are bundled together in appropriate places. -Such as when testing build_function(see the example below). Another advantage of bundeling is that tests for specific -bundles can be run separately. +In previous versions all tests were written as a standalone function, however, due to that, it is hard to orientate in +the code and it makes hard to know which test cases are related and which are not. To avoid that, tests in this release +are bundled together in inappropriate places. Such as when testing build_function(see the example below). Also, bundling +makes it easy to run all related tests at once, without having to run the whole test suit, thus making it faster to debug. ```python class TestSchema: @@ -34,13 +34,13 @@ class TestSchema: ## Using metadata templates For testing the V4 there are two sets of metadata. `tests/v4/metadata.xml` is filed with test entities, types, sets etc. -while the `tests/v4/metadata.template.xml` is only metadata skeleton. The latter is useful when there is need for -ceranty that any other metadata arent influensing the result, when custom elements are needed for specific test or when +while the `tests/v4/metadata.template.xml` is only metadata skeleton. The latter is useful when there is a need to be +sure that any other metadata arent influencing the result, when custom elements are needed for a specific test or when you are working with invalid metadata. -To use the metadata template the Ninja2 is requited. Ninja2 is template engine which can load up the template xml and -fill it with provided data. Fixture template_builder is available to all tests. Calling the fixture with array of EMD -elements will return MetadataBuilder already filled with your custom data. +To use the metadata template the Ninja2 is requited. Ninja2 is a template engine which can load up the template XML and +fill it with provided data. Fixture template_builder is available to all tests. Calling the fixture with an array of EMD +elements will return MetadataBuilder preloaded with your custom data. ```python faulty_entity = """ @@ -48,4 +48,4 @@ elements will return MetadataBuilder already filled with your custom data. """ builder, config = template_builder(ODataV4, schema_elements=[faulty_entity]) -``` \ No newline at end of file +``` diff --git a/deisgn-doc/TOC.md b/deisgn-doc/TOC.md index 6a29faf5..d37e4b3d 100644 --- a/deisgn-doc/TOC.md +++ b/deisgn-doc/TOC.md @@ -1,2 +1,2 @@ -- [Changes Overview](Overview.md) +- [Changes in PyOdata](Changes-in-pyodata.md) - [Changes in Tests](Changes-in-tests.md) \ No newline at end of file From 29a71bf6f82b85e375ff1c2d23a1a89c3c1667dd Mon Sep 17 00:00:00 2001 From: Jakub Filak Date: Wed, 10 Jun 2020 19:59:03 +0200 Subject: [PATCH 30/36] client: beautify version dealing --- pyodata/client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyodata/client.py b/pyodata/client.py index 1e9b02c8..0453aaf2 100644 --- a/pyodata/client.py +++ b/pyodata/client.py @@ -71,8 +71,8 @@ def __new__(cls, url, connection, namespaces=None, # create service instance based on model we have logger.info('Creating OData Service (version: %s)', str(config.odata_version)) - service = Service(url, schema, connection) - if config.odata_version == ODataV4: - return v4.Service(url, schema, connection) - - return service + try: + return {ODataV2: Service, + ODataV4: v4.Service}[config.odata_version](url, schema, connection) + except KeyError: + raise PyODataException(f'Bug: unhandled OData version {str(config.odata_version)}') From b3e033b18e8f797320d0092fa9035232bba59727 Mon Sep 17 00:00:00 2001 From: Jakub Filak Date: Wed, 10 Jun 2020 20:08:12 +0200 Subject: [PATCH 31/36] config: forward type declarations & explicit None --- pyodata/config.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/pyodata/config.py b/pyodata/config.py index 395626d8..470c81db 100644 --- a/pyodata/config.py +++ b/pyodata/config.py @@ -5,6 +5,12 @@ import pyodata.version +CustomErrorPolicies = Dict[ParserError, ErrorPolicyType] +ODataVersion = Type[pyodata.version.ODATAVersion] +Namespaces = Dict[str, str] +Aliases = Dict[str, str] + + class Config: # pylint: disable=too-many-instance-attributes,missing-docstring # All attributes have purpose and are used for configuration @@ -13,8 +19,8 @@ class Config: """ This is configuration class for PyOData. All session dependent settings should be stored here. """ def __init__(self, - odata_version: Type[pyodata.version.ODATAVersion], - custom_error_policies: Dict[ParserError, ErrorPolicyType] = None, + odata_version: ODataVersion, + custom_error_policies: CustomErrorPolicies = None, default_error_policy: ErrorPolicyType = None, xml_namespaces=None ): @@ -46,7 +52,7 @@ def __init__(self, self._sap_value_helper_directions = None self._annotation_namespaces = None - self._aliases: Dict[str, str] = dict() + self._aliases: Aliases = dict() def err_policy(self, error: ParserError) -> ErrorPolicyType: """ Returns error policy for given error. If custom error policy fo error is set, then returns that.""" @@ -67,29 +73,29 @@ def set_custom_error_policy(self, policies: Dict[ParserError, ErrorPolicyType]): self._custom_error_policy = policies @property - def namespaces(self) -> str: + def namespaces(self) -> Namespaces: return self._namespaces @namespaces.setter - def namespaces(self, value: Dict[str, str]): + def namespaces(self, value: Namespaces): self._namespaces = value @property - def odata_version(self) -> Type[pyodata.version.ODATAVersion]: + def odata_version(self) -> ODataVersion: return self._odata_version @property - def sap_value_helper_directions(self): + def sap_value_helper_directions(self) -> None: return self._sap_value_helper_directions @property - def aliases(self) -> Dict[str, str]: + def aliases(self) -> Aliases: return self._aliases @aliases.setter - def aliases(self, value: Dict[str, str]): + def aliases(self, value: Aliases): self._aliases = value @property - def annotation_namespace(self): + def annotation_namespace(self) -> None: return self._annotation_namespaces From 9638dfeb9e72cb1167f8d27e1fe9daef80bb28ec Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Mon, 22 Jun 2020 16:43:26 +0200 Subject: [PATCH 32/36] Add optional dependency into extras_require is setup.py. It was already included in the file optional-requirements.txt but it was missing from setup.py, which cased inconsistency. --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 56909b74..6363c63b 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ def _read(name): "lxml>=3.7.3", ], extras_require={ + "geojson" }, tests_require=[ "codecov", From 005854c1afebb653f38604f701567f0a71c98d0d Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Mon, 22 Jun 2020 16:55:42 +0200 Subject: [PATCH 33/36] Add logging for policy ignore The error message should never be thrown away. --- pyodata/policies.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyodata/policies.py b/pyodata/policies.py index 58f62425..a90d19c5 100644 --- a/pyodata/policies.py +++ b/pyodata/policies.py @@ -53,5 +53,9 @@ def resolve(self, ekseption): class PolicyIgnore(ErrorPolicy): """ Encounter error is ignored and parser continues as nothing has happened """ + def __init__(self): + logging.basicConfig(format='%(levelname)s: %(message)s') + self._logger = logging.getLogger() + def resolve(self, ekseption): - pass + self._logger.debug('[%s] %s', ekseption.__class__.__name__, str(ekseption)) From 9ef4cf5bddb01a7d61228e0304e56d1b6f4a0b17 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Mon, 22 Jun 2020 16:56:29 +0200 Subject: [PATCH 34/36] Fix formatting of policy.py --- pyodata/policies.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyodata/policies.py b/pyodata/policies.py index a90d19c5..d53d8194 100644 --- a/pyodata/policies.py +++ b/pyodata/policies.py @@ -30,6 +30,7 @@ class ParserError(Enum): class ErrorPolicy(ABC): """ All policies has to inhere this class""" + @abstractmethod def resolve(self, ekseption): """ This method is invoked when an error arise.""" @@ -37,12 +38,14 @@ def resolve(self, ekseption): class PolicyFatal(ErrorPolicy): """ Encounter error should result in parser failing. """ + def resolve(self, ekseption): raise ekseption class PolicyWarning(ErrorPolicy): """ Encounter error is logged, but parser continues as nothing has happened """ + def __init__(self): logging.basicConfig(format='%(levelname)s: %(message)s') self._logger = logging.getLogger() @@ -53,6 +56,7 @@ def resolve(self, ekseption): class PolicyIgnore(ErrorPolicy): """ Encounter error is ignored and parser continues as nothing has happened """ + def __init__(self): logging.basicConfig(format='%(levelname)s: %(message)s') self._logger = logging.getLogger() From fab171210dd688bc174fb9c02dbc2421b8b54d05 Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Mon, 22 Jun 2020 18:03:41 +0200 Subject: [PATCH 35/36] Add types aliases in version.py --- pyodata/v2/__init__.py | 9 ++++----- pyodata/v4/__init__.py | 9 ++++----- pyodata/version.py | 18 +++++++++++------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index c16e1d75..85f16ecb 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -1,10 +1,9 @@ """ This module represents implementation of ODATA V2 """ import logging -from typing import List -from pyodata.version import ODATAVersion +from pyodata.version import ODATAVersion, BuildFunctionDict, PrimitiveTypeList, BuildAnnotationDict from pyodata.model.elements import StructTypeProperty, StructType, ComplexType, EntityType, EntitySet, ValueHelper, \ ValueHelperParameter, FunctionImport, Typ from pyodata.model.build_functions import build_value_helper, build_entity_type, build_complex_type, \ @@ -28,7 +27,7 @@ class ODataV2(ODATAVersion): """ Definition of OData V2 """ @staticmethod - def build_functions(): + def build_functions() -> BuildFunctionDict: return { StructTypeProperty: build_struct_type_property, StructType: build_struct_type, @@ -47,7 +46,7 @@ def build_functions(): } @staticmethod - def primitive_types() -> List[Typ]: + def primitive_types() -> PrimitiveTypeList: return [ Typ('Null', 'null'), Typ('Edm.Binary', 'binary\'\''), @@ -68,7 +67,7 @@ def primitive_types() -> List[Typ]: ] @staticmethod - def annotations(): + def annotations() -> BuildAnnotationDict: return { ValueHelper: build_value_helper } diff --git a/pyodata/v4/__init__.py b/pyodata/v4/__init__.py index c68b42e0..82666fea 100644 --- a/pyodata/v4/__init__.py +++ b/pyodata/v4/__init__.py @@ -1,8 +1,7 @@ """ This module represents implementation of ODATA V4 """ -from typing import List -from pyodata.version import ODATAVersion +from pyodata.version import ODATAVersion, BuildFunctionDict, PrimitiveTypeList, BuildAnnotationDict from pyodata.model.elements import Typ, Schema, ComplexType, StructType, StructTypeProperty, EntityType from pyodata.model.build_functions import build_entity_type, build_complex_type, build_struct_type_property, \ build_struct_type @@ -20,7 +19,7 @@ class ODataV4(ODATAVersion): """ Definition of OData V4 """ @staticmethod - def build_functions(): + def build_functions() -> BuildFunctionDict: return { StructTypeProperty: build_struct_type_property, StructType: build_struct_type, @@ -35,7 +34,7 @@ def build_functions(): } @staticmethod - def primitive_types() -> List[Typ]: + def primitive_types() -> PrimitiveTypeList: # TODO: We currently lack support for: # 'Edm.Geometry', # 'Edm.GeometryPoint', @@ -75,7 +74,7 @@ def primitive_types() -> List[Typ]: ] @staticmethod - def annotations(): + def annotations() -> BuildAnnotationDict: return { Unit: build_unit_annotation } diff --git a/pyodata/version.py b/pyodata/version.py index ce6a74f9..b1b58b3f 100644 --- a/pyodata/version.py +++ b/pyodata/version.py @@ -1,13 +1,17 @@ """ Base class for defining ODATA versions. """ from abc import ABC, abstractmethod -from typing import List, Dict, Callable, TYPE_CHECKING - -# pylint: disable=cyclic-import +from typing import List, Dict, Callable, TYPE_CHECKING, Type if TYPE_CHECKING: + # pylint: disable=cyclic-import from pyodata.model.elements import Typ, Annotation # noqa +PrimitiveTypeDict = Dict[str, 'Typ'] +PrimitiveTypeList = List['Typ'] +BuildFunctionDict = Dict[type, Callable] +BuildAnnotationDict = Dict[Type['Annotation'], Callable] + class ODATAVersion(ABC): """ This is base class for different OData releases. In it we define what are supported types, elements and so on. @@ -19,21 +23,21 @@ def __init__(self): 'therefore you can not create instance of them') # Separate dictionary of all registered types (primitive, complex and collection variants) for each child - Types: Dict[str, 'Typ'] = dict() + Types: PrimitiveTypeDict = dict() @staticmethod @abstractmethod - def primitive_types() -> List['Typ']: + def primitive_types() -> PrimitiveTypeList: """ Here we define which primitive types are supported and what is their python representation""" @staticmethod @abstractmethod - def build_functions() -> Dict[type, Callable]: + def build_functions() -> BuildFunctionDict: """ Here we define which elements are supported and what is their python representation""" @staticmethod @abstractmethod - def annotations() -> Dict['Annotation', Callable]: + def annotations() -> BuildAnnotationDict: """ Here we define which annotations are supported and what is their python representation""" # # @staticmethod From d9348cd1523fa771e27d73c38850f4459104317a Mon Sep 17 00:00:00 2001 From: Martin Miksik Date: Mon, 22 Jun 2020 18:17:44 +0200 Subject: [PATCH 36/36] Add types to builder.py --- pyodata/model/builder.py | 11 ++++++++--- pyodata/type_declarations.py | 8 ++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 pyodata/type_declarations.py diff --git a/pyodata/model/builder.py b/pyodata/model/builder.py index a7155293..2d171210 100644 --- a/pyodata/model/builder.py +++ b/pyodata/model/builder.py @@ -1,12 +1,17 @@ """Metadata Builder Implementation""" import io +from typing import TypeVar, Dict + from lxml import etree from pyodata.config import Config from pyodata.exceptions import PyODataParserError from pyodata.model.elements import ValueHelperParameter, Schema, build_element +from pyodata.type_declarations import ETreeType +XMLType = TypeVar('XMLType', str, bytes) +AliasesType = Dict[str, str] ANNOTATION_NAMESPACES = { 'edm': 'http://docs.oasis-open.org/odata/ns/edm', @@ -39,7 +44,7 @@ class MetadataBuilder: 'http://docs.oasis-open.org/odata/ns/edm' ] - def __init__(self, xml, config): + def __init__(self, xml: XMLType, config: Config): self._xml = xml self._config = config @@ -99,7 +104,7 @@ def build(self): return build_element(Schema, self._config, schema_nodes=edm_schemas) @staticmethod - def get_aliases(edmx, config: Config): + def get_aliases(edmx: ETreeType, config: Config): """Get all aliases""" # aliases = collections.defaultdict(set) @@ -117,7 +122,7 @@ def get_aliases(edmx, config: Config): return aliases @staticmethod - def update_alias(aliases, config: Config): + def update_alias(aliases: AliasesType, config: Config): """Update config with aliases""" config.aliases = aliases helper_direction_keys = list(config.sap_value_helper_directions.keys()) diff --git a/pyodata/type_declarations.py b/pyodata/type_declarations.py new file mode 100644 index 00000000..8046ebe2 --- /dev/null +++ b/pyodata/type_declarations.py @@ -0,0 +1,8 @@ +# pylint: disable=invalid-name +""" Place for type definitions shared in OData + All types aliases and declarations should contain "Type" suffix +""" + +from typing import Any + +ETreeType = Any