diff --git a/.gitignore b/.gitignore index 85b79ba3..04d8f9f6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ venv dist .idea .coverage +.htmlcov/ 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/.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 3c0ae7be..e67254d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,33 @@ 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 +- 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 +- 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 +- 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 +- 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 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 +- Type hinting for ErrorPolicy's children - Martin Miksik +- Error when printing navigation property without partner value - Martin Miksik ## [1.3.0] diff --git a/Makefile b/Makefile index a39b2ad4..56f96462 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 -d -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/bin/pyodata b/bin/pyodata index 5ef67db8..8082dc7f 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,8 +46,13 @@ 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: + 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}') @@ -90,6 +99,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 +155,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]()) diff --git a/deisgn-doc/Changes-in-pyodata.md b/deisgn-doc/Changes-in-pyodata.md new file mode 100644 index 00000000..aac81e64 --- /dev/null +++ b/deisgn-doc/Changes-in-pyodata.md @@ -0,0 +1,131 @@ + +# 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) +3. [Annotations](#Annotations) +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. +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) +``` + +## 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 + + +// Author note: Should be StrucType removed from the definition of build_functions? diff --git a/deisgn-doc/Changes-in-tests.md b/deisgn-doc/Changes-in-tests.md new file mode 100644 index 00000000..ef4cba8d --- /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 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: + 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 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 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 = """ + + + """ + builder, config = template_builder(ODataV4, schema_elements=[faulty_entity]) +``` diff --git a/deisgn-doc/TOC.md b/deisgn-doc/TOC.md new file mode 100644 index 00000000..d37e4b3d --- /dev/null +++ b/deisgn-doc/TOC.md @@ -0,0 +1,2 @@ +- [Changes in PyOdata](Changes-in-pyodata.md) +- [Changes in Tests](Changes-in-tests.md) \ No newline at end of file diff --git a/deisgn-doc/file-hierarchy.png b/deisgn-doc/file-hierarchy.png new file mode 100644 index 00000000..44131c13 Binary files /dev/null and b/deisgn-doc/file-hierarchy.png differ 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/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/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/client.py b/pyodata/client.py index a5e8b612..0453aaf2 100644 --- a/pyodata/client.py +++ b/pyodata/client.py @@ -3,9 +3,14 @@ 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 +from pyodata.v4 import ODataV4 + +import pyodata.v4 as v4 def _fetch_metadata(connection, url, logger): @@ -34,43 +39,40 @@ 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: %s)', str(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: %s)', str(config.odata_version)) - raise PyODataException('No implementation for selected odata version {}'.format(odata_version)) + 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)}') diff --git a/pyodata/config.py b/pyodata/config.py new file mode 100644 index 00000000..470c81db --- /dev/null +++ b/pyodata/config.py @@ -0,0 +1,101 @@ +""" Contains definition of configuration class for PyOData""" + +from typing import Type, Dict +from pyodata.policies import PolicyFatal, ParserError, ErrorPolicyType +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 + # 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: ODataVersion, + custom_error_policies: CustomErrorPolicies = None, + default_error_policy: ErrorPolicyType = 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._annotation_namespaces = None + 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.""" + 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: 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, 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. + """ + self._custom_error_policy = policies + + @property + def namespaces(self) -> Namespaces: + return self._namespaces + + @namespaces.setter + def namespaces(self, value: Namespaces): + self._namespaces = value + + @property + def odata_version(self) -> ODataVersion: + return self._odata_version + + @property + def sap_value_helper_directions(self) -> None: + return self._sap_value_helper_directions + + @property + def aliases(self) -> Aliases: + return self._aliases + + @aliases.setter + def aliases(self, value: Aliases): + self._aliases = value + + @property + def annotation_namespace(self) -> None: + return self._annotation_namespaces 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/model/__init__.py b/pyodata/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyodata/model/build_functions.py b/pyodata/model/build_functions.py new file mode 100644 index 00000000..186a2ef3 --- /dev/null +++ b/pyodata/model/build_functions.py @@ -0,0 +1,222 @@ +""" Reusable implementation of build functions for the most of edm elements """ + +# pylint: disable=unused-argument, missing-docstring, invalid-name +import copy +import logging + +from pyodata.policies import ParserError +from pyodata.config import Config +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, NullType + + +def modlog(): + return logging.getLogger("callbacks") + + +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')), + 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 build_struct_type(config: Config, type_node, typ, schema=None): + name = type_node.get('Name') + base_type = type_node.get('BaseType') + + 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') + except AttributeError: + raise PyODataParserError(f'\'{base_type.name}\' ') + + stype._name = name + + for proprty in type_node.xpath('edm:Property', namespaces=config.namespaces): + stp = build_element(StructTypeProperty, config, entity_type_property_node=proprty) + + if stp.name in stype._properties: + raise PyODataParserError('{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()): + if ctp.struct_type is None: # TODO: Is it correct + ctp.struct_type = stype + + return stype + + +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 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): + 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:NavigationProperty', namespaces=config.namespaces): + navp = build_element('NavigationTypeProperty', config, node=proprty) + + if navp.name in etype._nav_properties: + raise PyODataParserError('{0} already has navigation property {1}'.format(etype, navp.name)) + + etype._nav_properties[navp.name] = navp + + return etype + except (PyODataParserError, 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, builder=None): + # pylint: disable=too-many-locals + 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') + + 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) + + +def build_value_helper(config, target, annotation_node, schema): + # pylint: disable=too-many-locals + label = None + collection_path = None + search_supported = False + params_node = None + + 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 = build_element(ValueHelperParameter, config, value_help_parameter_node=prm) + param.value_helper = value_helper + value_helper._parameters.append(param) + + try: + 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 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 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 (PyODataModelError, PyODataParserError) as ex: + config.err_policy(ParserError.ANNOTATION).resolve(ex) + + +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 + 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 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') + + 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/builder.py b/pyodata/model/builder.py new file mode 100644 index 00000000..2d171210 --- /dev/null +++ b/pyodata/model/builder.py @@ -0,0 +1,135 @@ +"""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', + '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 +} + + +# 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: XMLType, config: Config): + self._xml = xml + 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._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 build_element(Schema, self._config, schema_nodes=edm_schemas) + + @staticmethod + def get_aliases(edmx: ETreeType, config: Config): + """Get all aliases""" + + # 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) + 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[alias] = namespace + # aliases[namespace].add(alias) + + return aliases + + @staticmethod + def update_alias(aliases: AliasesType, config: Config): + """Update config with aliases""" + 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, alias_namespace in aliases.items(): + if alias_namespace == namespace: + config._sap_value_helper_directions[alias + '.' + suffix] = \ + config.sap_value_helper_directions[direction_key] diff --git a/pyodata/model/elements.py b/pyodata/model/elements.py new file mode 100644 index 00000000..1a6dc0f8 --- /dev/null +++ b/pyodata/model/elements.py @@ -0,0 +1,1191 @@ +# pylint: disable=too-many-lines, missing-docstring, too-many-arguments, too-many-instance-attributes + +import collections +import itertools +import logging +from abc import abstractmethod +from enum import Enum +from typing import Union, Dict + +from pyodata.policies import ParserError +from pyodata.config import Config +from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError + +from pyodata.model.type_traits import TypTraits, EdmStructTypTraits + + +IdentifierInfo = collections.namedtuple('IdentifierInfo', 'namespace name') +TypeInfo = collections.namedtuple('TypeInfo', 'namespace name is_collection') + + +def modlog(): + return logging.getLogger("Elements") + + +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__ + + callbacks = config.odata_version.build_functions() + for clb in callbacks: + if element_name == clb.__name__: + return callbacks[clb](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 + + def __getattr__(self, item): + 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 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: str): + super(Identifier, self).__init__() + + self._name = name + + def __repr__(self): + return "{0}({1})".format(self.__class__.__name__, self._name) + + def __str__(self): + return "{0}({1})".format(self.__class__.__name__, self._name) + + @property + def name(self): + return self._name + + @staticmethod + def parse(value): + 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: + """ 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 + rule is: don't create any type instances if not necessary, + always reuse existing instances if possible + """ + + @staticmethod + def register_type(typ: 'Typ', config: Config): + """Add new type to the ODATA version type repository as well as its collection variant""" + + o_version = config.odata_version + + # register type only if it doesn't exist + 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) + if collection_name not in o_version.Types: + collection_typ = Collection(typ.name, typ) + o_version.Types[collection_name] = collection_typ + + @staticmethod + def from_name(name, config: Config) -> 'Typ': + o_version = config.odata_version + + # build types hierarchy on first use (lazy creation) + if not o_version.Types: + o_version.Types = dict() + for typ in o_version.primitive_types(): + Types.register_type(typ, config) + + search_name = name + + # detect if name represents collection + is_collection = name.lower().startswith('collection(') and name.endswith(')') + if is_collection: + name = name[11:-1] # strip collection() decorator + search_name = 'Collection({})'.format(name) + + try: + return o_version.Types[search_name] + except KeyError: + raise PyODataModelError(f'Requested primitive type {search_name} is not supported in this version of ODATA') + + @staticmethod + def parse_type_name(type_name): + + # detect if name represents collection + is_collection = type_name.lower().startswith('collection(') and type_name.endswith(')') + if is_collection: + type_name = type_name[11:-1] # strip collection() decorator + + identifier = Identifier.parse(type_name) + + if identifier.namespace == 'Edm': + return TypeInfo(None, type_name, is_collection) + + return TypeInfo(identifier.namespace, identifier.name, is_collection) + + +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) + + 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): + return self._null_value + + @property + def traits(self): + return self._traits + + @property + def is_collection(self): + return False + + @property + 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""" + + def __init__(self, name, item_type): + super(Collection, self).__init__(name, [], kind=item_type.kind) + self._item_type = item_type + + def __repr__(self): + return 'Collection({})'.format(repr(self._item_type)) + + @property + def is_collection(self): + return True + + @property + def item_type(self): + return self._item_type + + @property + def traits(self): + return self + + # pylint: disable=no-self-use + def to_literal(self, value): + if not isinstance(value, list): + raise PyODataException('Bad format: invalid list value {}'.format(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): + raise PyODataException('Bad format: invalid list value {}'.format(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 + + def __init__(self, name, type_info, nullable, max_length, precision, scale): + super(VariableDeclaration, self).__init__(name) + + self._type_info = type_info + self._typ = None + + self._nullable = bool(nullable) + + if not max_length: + self._max_length = None + elif max_length.upper() == 'MAX': + self._max_length = VariableDeclaration.MAXIMUM_LENGTH + else: + self._max_length = int(max_length) + + if not precision: + self._precision = None + else: + self._precision = int(precision) + if not scale: + self._scale = 0 + else: + self._scale = int(scale) + self._check_scale_value() + + @property + def type_info(self): + return self._type_info + + @property + def typ(self): + return self._typ + + @typ.setter + def typ(self, value): + if self._typ is not None: + raise PyODataModelError('Cannot replace {0} of {1} by {2}'.format(self._typ, self, value)) + + if value.name != self._type_info[1]: + raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) + + self._typ = value + + @property + def nullable(self): + return self._nullable + + @property + def max_length(self): + return self._max_length + + @property + def precision(self): + return self._precision + + @property + def scale(self): + return self._scale + + def _check_scale_value(self): + 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)) + + +class Schema: + class Declaration: + def __init__(self, namespace): + super(Schema.Declaration, self).__init__() + + self.namespace = namespace + + self.entity_types = dict() + self.complex_types = dict() + self.enum_types = dict() + self.entity_sets = dict() + 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()) + + def list_complex_types(self): + return list(self.complex_types.values()) + + def list_enum_types(self): + return list(self.enum_types.values()) + + def list_entity_sets(self): + return list(self.entity_sets.values()) + + def list_function_imports(self): + return list(self.function_imports.values()) + + def list_associations(self): + return list(self.associations.values()) + + 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""" + + self.entity_types[etype.name] = etype + + # automatically create and register collection variant if not exists + if isinstance(etype, NullType): + return + + collection_type_name = 'Collection({})'.format(etype.name) + self.entity_types[collection_type_name] = Collection(etype.name, etype) + + def add_complex_type(self, ctype): + """Add new complex type to the type repository as well as its collection variant""" + + self.complex_types[ctype.name] = ctype + + # automatically create and register collection variant if not exists + if isinstance(ctype, NullType): + return + + collection_type_name = 'Collection({})'.format(ctype.name) + self.complex_types[collection_type_name] = Collection(ctype.name, ctype) + + 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): + try: + return super(Schema.Declarations, self).__getitem__(key) + except KeyError: + raise PyODataModelError('There is no Schema Namespace {}'.format(key)) + + def __init__(self, config: Config): + super(Schema, self).__init__() + + self._decls = Schema.Declarations() + self._config = config + + def __str__(self): + return "{0}({1})".format(self.__class__.__name__, ','.join(self.namespaces)) + + @property + def namespaces(self): + return list(self._decls.keys()) + + @property + def config(self): + return self._config + + def typ(self, type_name, namespace=None): + """Returns either EntityType, ComplexType or EnumType that matches the name. + """ + + for type_space in (self.entity_type, self.complex_type, self.enum_type): + try: + return type_space(type_name, namespace=namespace) + except PyODataModelError: + pass + + 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 PyODataModelError('EntityType {} does not exist in Schema Namespace {}' + .format(type_name, namespace)) + + for decl in list(self._decls.values()): + try: + return decl.entity_types[type_name] + except KeyError: + pass + + 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 PyODataModelError('ComplexType {} does not exist in Schema Namespace {}' + .format(type_name, namespace)) + + for decl in list(self._decls.values()): + try: + return decl.complex_types[type_name] + except KeyError: + pass + + 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 PyODataModelError(f'EnumType {type_name} does not exist in Schema Namespace {namespace}') + + for decl in list(self._decls.values()): + try: + return decl.enum_types[type_name] + except KeyError: + pass + + 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 PyODataModelError(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 PyODataModelError(f'EnumType {name} does not exist in any Schema Namespace') + + def get_type(self, type_info): + + # construct search name based on collection information + search_name = type_info.name if not type_info.is_collection else 'Collection({})'.format(type_info.name) + + # first look for type in primitive types + try: + return Types.from_name(search_name, self.config) + except PyODataModelError: + pass + + # then look for type in type definitions + try: + return self.type_definition(search_name, type_info.namespace) + except PyODataModelError: + pass + + # then look for type in entity types + try: + return self.entity_type(search_name, type_info.namespace) + except PyODataModelError: + pass + + # then look for type in complex types + try: + return self.complex_type(search_name, type_info.namespace) + except PyODataModelError: + pass + + # then look for type in enum types + try: + return self.enum_type(search_name, type_info.namespace) + except PyODataModelError: + pass + + raise PyODataModelError( + 'Neither primitive types nor types parsed from service metadata contain requested type {}' + .format(type_info.name)) + + @property + def entity_types(self): + return list(itertools.chain(*(decl.list_entity_types() for decl in list(self._decls.values())))) + + @property + def complex_types(self): + return list(itertools.chain(*(decl.list_complex_types() for decl in list(self._decls.values())))) + + @property + def enum_types(self): + return list(itertools.chain(*(decl.list_enum_types() for decl in list(self._decls.values())))) + + def entity_set(self, set_name, namespace=None): + if namespace is not None: + try: + return self._decls[namespace].entity_sets[set_name] + except KeyError: + raise PyODataModelError('EntitySet {} does not exist in Schema Namespace {}' + .format(set_name, namespace)) + + for decl in list(self._decls.values()): + try: + return decl.entity_sets[set_name] + except KeyError: + pass + + raise PyODataModelError('EntitySet {} does not exist in any Schema Namespace'.format(set_name)) + + @property + def entity_sets(self): + return list(itertools.chain(*(decl.list_entity_sets() for decl in list(self._decls.values())))) + + def function_import(self, function_import, namespace=None): + if namespace is not None: + try: + return self._decls[namespace].function_imports[function_import] + except KeyError: + raise PyODataModelError('FunctionImport {} does not exist in Schema Namespace {}' + .format(function_import, namespace)) + + for decl in list(self._decls.values()): + try: + return decl.function_imports[function_import] + except KeyError: + pass + + raise PyODataModelError('FunctionImport {} does not exist in any Schema Namespace'.format(function_import)) + + @property + def function_imports(self): + return list(itertools.chain(*(decl.list_function_imports() 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: + entity_type = self.entity_type(entity_type_name, namespace) + except KeyError: + raise PyODataModelError('EntityType {} does not exist in Schema Namespace {}' + .format(entity_type_name, namespace)) + try: + entity_type.proprty(proprty) + except KeyError: + raise PyODataModelError('Property {} does not exist in {}'.format(proprty, entity_type.name)) + + +class StructType(Typ): + def __init__(self, name, label, is_value_list): + super(StructType, self).__init__(name, None, EdmStructTypTraits(self), Typ.Kinds.Complex) + + self._label = label + self._is_value_list = is_value_list + self._key = list() + self._properties: Dict[str, 'StructTypeProperty'] = dict() + + @property + def label(self): + return self._label + + @property + def is_value_list(self): + return self._is_value_list + + def proprty(self, property_name: str) -> 'StructTypeProperty': + 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()) + + # implementation of Typ interface + @property + def is_collection(self): + return False + + @property + def kind(self): + return Typ.Kinds.Complex + + @property + def null_value(self): + return None + + @property + def traits(self): + # return self._traits + return EdmStructTypTraits(self) + + +class ComplexType(StructType): + """Representation of Edm.ComplexType""" + + +class EntityType(StructType): + def __init__(self, name, label, is_value_list): + super(EntityType, self).__init__(name, label, is_value_list) + + self._key = list() + self._nav_properties = dict() + + @property + def key_proprties(self): + return list(self._key) + + @property + def nav_proprties(self): + """Gets the navigation properties defined for this entity type""" + return list(self._nav_properties.values()) + + def nav_proprty(self, 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): + def __init__(self, name, entity_type_info, addressable, creatable, updatable, deletable, searchable, countable, + pageable, topable, req_filter, label): + super(EntitySet, self).__init__(name) + + self._entity_type_info = entity_type_info + self._entity_type = None + self._addressable = addressable + self._creatable = creatable + self._updatable = updatable + self._deletable = deletable + self._searchable = searchable + self._countable = countable + self._pageable = pageable + self._topable = topable + self._req_filter = req_filter + self._label = label + + @property + def entity_type_info(self): + return self._entity_type_info + + @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[1]: + raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) + + self._entity_type = value + + @property + def addressable(self): + return self._addressable + + @property + def creatable(self): + return self._creatable + + @property + def updatable(self): + return self._updatable + + @property + def deletable(self): + return self._deletable + + @property + def searchable(self): + return self._searchable + + @property + def countable(self): + return self._countable + + @property + def pageable(self): + return self._pageable + + @property + def topable(self): + return self._topable + + @property + def requires_filter(self): + return self._req_filter + + @property + def label(self): + return self._label + + +class StructTypeProperty(VariableDeclaration): + """Property of structure types (Entity/Complex type) + + Type of the property can be: + * primitive type + * complex type + * enumeration type (in version 4) + * collection of one of previous + """ + + # pylint: disable=too-many-locals + def __init__(self, name, type_info, nullable, max_length, precision, scale, uncode, label, creatable, updatable, + sortable, filterable, filter_restr, req_in_filter, text, visible, display_format, value_list): + super(StructTypeProperty, self).__init__(name, type_info, nullable, max_length, precision, scale) + + self._value_helper = None + self._struct_type = None + self._uncode = uncode + self._label = label + self._creatable = creatable + self._updatable = updatable + self._sortable = sortable + self._filterable = filterable + self._filter_restr = filter_restr + self._req_in_filter = req_in_filter + self._text_proprty_name = text + self._visible = visible + self._display_format = display_format + self._value_list = value_list + + # Lazy loading + self._text_proprty = None + + @property + def struct_type(self): + return self._struct_type + + @struct_type.setter + def struct_type(self, value): + + if self._struct_type is not None: + raise PyODataModelError('Cannot replace {0} of {1} to {2}'.format(self._struct_type, self, value)) + + self._struct_type = value + + if self._text_proprty_name is not None: + try: + self._text_proprty = self._struct_type.proprty(self._text_proprty_name) + except KeyError: + # TODO: resolve EntityType of text property + if '/' not in self._text_proprty_name: + 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): + return self._text_proprty_name + + @property + def text_proprty(self): + return self._text_proprty + + @property + def uncode(self): + return self._uncode + + @property + def label(self): + return self._label + + @property + def creatable(self): + return self._creatable + + @property + def updatable(self): + return self._updatable + + @property + def sortable(self): + return self._sortable + + @property + def filterable(self): + return self._filterable + + @property + def filter_restriction(self): + return self._filter_restr + + @property + def required_in_filter(self): + return self._req_in_filter + + @property + def visible(self): + return self._visible + + @property + def upper_case(self): + return self._display_format == 'UpperCase' + + @property + def date(self): + return self._display_format == 'Date' + + @property + def non_negative(self): + return self._display_format == 'NonNegative' + + @property + def value_helper(self): + return self._value_helper + + @property + def value_list(self): + return self._value_list + + @value_helper.setter + def value_helper(self, value): + # Value Help property must not be changed + if self._value_helper is not None: + raise PyODataModelError('Cannot replace value helper {0} of {1} by {2}' + .format(self._value_helper, self, value)) + + self._value_helper = value + + +class Annotation: + + def __init__(self, target, qualifier=None): + super(Annotation, self).__init__() + + 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 + + @property + def element(self): + return self._element + + @property + def target(self): + return '{0}.{1}'.format(self._element_namespace, self._element) + + +class ValueHelper(Annotation): + def __init__(self, target, collection_path, label, search_supported): + + # pylint: disable=unused-argument + + super(ValueHelper, self).__init__(target) + + self._entity_type_name, self._proprty_name = self.element.split('/') + self._proprty = None + + self._collection_path = collection_path + self._entity_set = None + + self._label = label + self._parameters = list() + + 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 + + @property + def proprty_entity_type_name(self): + return self._entity_type_name + + @property + def proprty(self): + return self._proprty + + @proprty.setter + def proprty(self, value): + if self._proprty is not None: + 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 PyODataModelError('{0} cannot be an annotation of {1}'.format(self, value)) + + self._proprty = value + + for param in self._parameters: + if param.local_property_name: + etype = self._proprty.struct_type + try: + param.local_property = etype.proprty(param.local_property_name) + 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 + def collection_path(self): + return self._collection_path + + @property + def entity_set(self): + return self._entity_set + + @entity_set.setter + def entity_set(self, value): + if self._entity_set is not None: + raise PyODataModelError('Cannot replace {0} of {1} with {2}'.format(self._entity_set, self, value)) + + if value.name != self.collection_path: + raise PyODataModelError('{0} cannot be assigned to {1}'.format(self, value)) + + self._entity_set = value + + for param in self._parameters: + if param.list_property_name: + etype = self._entity_set.entity_type + try: + param.list_property = etype.proprty(param.list_property_name) + 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 + def label(self): + return self._label + + @property + def parameters(self): + return self._parameters + + def local_property_param(self, name): + for prm in self._parameters: + if prm.local_property.name == name: + return prm + + 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 PyODataModelError('{0} has no list property {1}'.format(self, name)) + + +class ValueHelperParameter(): + Direction = Enum('Direction', 'In InOut Out DisplayOnly FilterOnly') + + def __init__(self, direction, local_property_name, list_property_name): + super(ValueHelperParameter, self).__init__() + + self._direction = direction + self._value_helper = None + + self._local_property = None + self._local_property_name = local_property_name + + self._list_property = None + self._list_property_name = list_property_name + + def __str__(self): + if self._direction in [ValueHelperParameter.Direction.DisplayOnly, ValueHelperParameter.Direction.FilterOnly]: + return "{0}({1})".format(self.__class__.__name__, self._list_property_name) + + return "{0}({1}={2})".format(self.__class__.__name__, self._local_property_name, self._list_property_name) + + @property + def value_helper(self): + return self._value_helper + + @value_helper.setter + def value_helper(self, value): + if self._value_helper is not None: + raise PyODataModelError('Cannot replace {0} of {1} with {2}'.format(self._value_helper, self, value)) + + self._value_helper = value + + @property + def direction(self): + return self._direction + + @property + def local_property_name(self): + return self._local_property_name + + @property + def local_property(self): + return self._local_property + + @local_property.setter + def local_property(self, value): + if self._local_property is not None: + raise PyODataModelError('Cannot replace {0} of {1} with {2}'.format(self._local_property, self, value)) + + self._local_property = value + + @property + def list_property_name(self): + return self._list_property_name + + @property + def list_property(self): + return self._list_property + + @list_property.setter + def list_property(self, value): + if self._list_property is not None: + raise PyODataModelError('Cannot replace {0} of {1} with {2}'.format(self._list_property, self, value)) + + self._list_property = value + + +class FunctionImport(Identifier): + def __init__(self, name, return_type_info, entity_set, parameters, http_method='GET'): + super(FunctionImport, self).__init__(name) + + self._entity_set_name = entity_set + self._return_type_info = return_type_info + self._return_type = None + self._parameters = parameters + self._http_method = http_method + + @property + def return_type_info(self): + return self._return_type_info + + @property + def return_type(self): + return self._return_type + + @return_type.setter + def return_type(self, value): + if self._return_type is not None: + raise PyODataModelError('Cannot replace {0} of {1} by {2}'.format(self._return_type, self, value)) + + if value.name != self.return_type_info[1]: + raise PyODataModelError('{0} cannot be the type of {1}'.format(value, self)) + + self._return_type = value + + @property + def entity_set_name(self): + return self._entity_set_name + + @property + def parameters(self): + return list(self._parameters.values()) + + def get_parameter(self, parameter): + return self._parameters[parameter] + + @property + def http_method(self): + return self._http_method + + +class FunctionImportParameter(VariableDeclaration): + Modes = Enum('Modes', 'In Out InOut') + + def __init__(self, name, type_info, nullable, max_length, precision, scale, mode): + super(FunctionImportParameter, self).__init__(name, type_info, nullable, max_length, precision, scale) + + self._mode = mode + + @property + def mode(self): + return self._mode + + +def sap_attribute_get(node, attr): + return node.get('{http://www.sap.com/Protocols/SAPData}%s' % (attr)) + + +def metadata_attribute_get(node, attr): + return node.get('{http://schemas.microsoft.com/ado/2007/08/dataservices/metadata}%s' % (attr)) + + +def sap_attribute_get_string(node, attr): + return sap_attribute_get(node, attr) + + +def sap_attribute_get_bool(node, attr, default): + value = sap_attribute_get(node, attr) + if value is None: + return default + + if value == 'true': + return True + + if value == 'false': + return False + + raise TypeError('Not a bool attribute: {0} = {1}'.format(attr, value)) diff --git a/pyodata/model/type_traits.py b/pyodata/model/type_traits.py new file mode 100644 index 00000000..9c6b2961 --- /dev/null +++ b/pyodata/model/type_traits.py @@ -0,0 +1,180 @@ +# pylint: disable=missing-docstring + +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 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) diff --git a/pyodata/policies.py b/pyodata/policies.py new file mode 100644 index 00000000..d53d8194 --- /dev/null +++ b/pyodata/policies.py @@ -0,0 +1,65 @@ +""" + 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 +from typing import TypeVar + + +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() + + TYPE_DEFINITION = auto() + ENUM_TYPE = auto() + ENTITY_TYPE = auto() + ENTITY_SET = auto() + COMPLEX_TYPE = auto() + REFERENTIAL_CONSTRAINT = auto() + + +ErrorPolicyType = TypeVar("ErrorPolicyType", bound="ErrorPolicy") + + +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 __init__(self): + logging.basicConfig(format='%(levelname)s: %(message)s') + self._logger = logging.getLogger() + + def resolve(self, ekseption): + self._logger.debug('[%s] %s', ekseption.__class__.__name__, str(ekseption)) 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 diff --git a/pyodata/v2/__init__.py b/pyodata/v2/__init__.py index e69de29b..85f16ecb 100644 --- a/pyodata/v2/__init__.py +++ b/pyodata/v2/__init__.py @@ -0,0 +1,73 @@ +""" This module represents implementation of ODATA V2 """ + +import logging + + +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, \ + 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 .elements import NavigationTypeProperty, EndRole, Association, AssociationSetEndRole, AssociationSet, \ + ReferentialConstraint, Schema +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(): + """ Logging function for debugging.""" + return logging.getLogger("v2") + + +class ODataV2(ODATAVersion): + """ Definition of OData V2 """ + + @staticmethod + def build_functions() -> BuildFunctionDict: + return { + 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 + def primitive_types() -> PrimitiveTypeList: + return [ + 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\'') + ] + + @staticmethod + def annotations() -> BuildAnnotationDict: + return { + ValueHelper: build_value_helper + } diff --git a/pyodata/v2/build_functions.py b/pyodata/v2/build_functions.py new file mode 100644 index 00000000..289e1534 --- /dev/null +++ b/pyodata/v2/build_functions.py @@ -0,0 +1,300 @@ +""" 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 typing import List + +from pyodata.config import Config +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 + + +def modlog(): + """ Logging function for debugging.""" + return logging.getLogger("v2_build_functions") + + +# 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 complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces): + 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): + 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): + 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 PyODataParserError( + '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 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, PyODataParserError) 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 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 + + # 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): + 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: + 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 + + +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 PyODataParserError('End type is not specified in the association {}'.format(name)) + association._end_roles.append(end_role) + + if len(association._end_roles) != 2: + 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 PyODataParserError('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 PyODataParserError('Referential constraint must contain exactly one principal element') + + principal_name = principal[0].get('Role') + if principal_name is None: + 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 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 PyODataParserError('Referential constraint must contain exactly one dependent element') + + dependent_name = dependent[0].get('Role') + if dependent_name is None: + 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 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 new file mode 100644 index 00000000..ba924cf0 --- /dev/null +++ b/pyodata/v2/elements.py @@ -0,0 +1,325 @@ +""" 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 PyODataModelError('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 PyODataModelError('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 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 PyODataModelError('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 PyODataModelError('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 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 PyODataModelError('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 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] + 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/model.py b/pyodata/v2/model.py deleted file mode 100644 index 9832a14c..00000000 --- a/pyodata/v2/model.py +++ /dev/null @@ -1,2513 +0,0 @@ -""" -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 - -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 lxml import etree - -from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError - -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) - - -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 - - 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 - - -class Identifier: - def __init__(self, name): - super(Identifier, self).__init__() - - self._name = name - - def __repr__(self): - return "{0}({1})".format(self.__class__.__name__, self._name) - - def __str__(self): - return "{0}({1})".format(self.__class__.__name__, self._name) - - @property - def name(self): - return self._name - - @staticmethod - def parse(value): - parts = value.split('.') - - if len(parts) == 1: - return IdentifierInfo(None, value) - - return IdentifierInfo('.'.join(parts[:-1]), parts[-1]) - - -class Types: - """Repository of all available OData types - - Since each type has instance of appropriate type, this - repository acts as central storage for all instances. The - rule is: don't create any type instances if not necessary, - 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\'')) - - @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() - - # 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 - - # 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: - collection_typ = Collection(typ.name, typ) - # pylint: disable=unsupported-assignment-operation - Types.Types[collection_name] = collection_typ - - @staticmethod - def from_name(name): - - # build types hierarchy on first use (lazy creation) - if Types.Types is None: - Types._build_types() - - search_name = name - - # detect if name represents collection - is_collection = name.lower().startswith('collection(') and name.endswith(')') - if is_collection: - name = name[11:-1] # strip collection() decorator - search_name = 'Collection({})'.format(name) - - # pylint: disable=unsubscriptable-object - return Types.Types[search_name] - - @staticmethod - def parse_type_name(type_name): - - # detect if name represents collection - is_collection = type_name.lower().startswith('collection(') and type_name.endswith(')') - if is_collection: - type_name = type_name[11:-1] # strip collection() decorator - - identifier = Identifier.parse(type_name) - - if identifier.namespace == 'Edm': - return TypeInfo(None, type_name, is_collection) - - 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') - - def __init__(self, name, null_value, traits=TypTraits(), kind=None): - super(Typ, self).__init__(name) - - 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 - - @property - def null_value(self): - return self._null_value - - @property - def traits(self): - return self._traits - - @property - def is_collection(self): - return False - - @property - def kind(self): - return self._kind - - -class Collection(Typ): - """Represents collection items""" - - def __init__(self, name, item_type): - super(Collection, self).__init__(name, [], kind=item_type.kind) - self._item_type = item_type - - def __repr__(self): - return 'Collection({})'.format(repr(self._item_type)) - - @property - def is_collection(self): - return True - - @property - def item_type(self): - return self._item_type - - @property - def traits(self): - return self - - # pylint: disable=no-self-use - def to_literal(self, value): - if not isinstance(value, list): - raise PyODataException('Bad format: invalid list value {}'.format(value)) - - return [self._item_type.traits.to_literal(v) for v in value] - - # pylint: disable=no-self-use - def from_json(self, value): - if not isinstance(value, list): - raise PyODataException('Bad format: invalid list value {}'.format(value)) - - return [self._item_type.traits.from_json(v) for v in value] - - -class VariableDeclaration(Identifier): - MAXIMUM_LENGTH = -1 - - def __init__(self, name, type_info, nullable, max_length, precision, scale): - super(VariableDeclaration, self).__init__(name) - - self._type_info = type_info - self._typ = None - - self._nullable = bool(nullable) - - if not max_length: - self._max_length = None - elif max_length.upper() == 'MAX': - self._max_length = VariableDeclaration.MAXIMUM_LENGTH - else: - self._max_length = int(max_length) - - if not precision: - self._precision = 0 - else: - self._precision = int(precision) - if not scale: - self._scale = 0 - else: - self._scale = int(scale) - self._check_scale_value() - - @property - def type_info(self): - return self._type_info - - @property - def typ(self): - return self._typ - - @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)) - - if value.name != self._type_info[1]: - raise RuntimeError('{0} cannot be the type of {1}'.format(value, self)) - - self._typ = value - - @property - def nullable(self): - return self._nullable - - @property - def max_length(self): - return self._max_length - - @property - def precision(self): - return self._precision - - @property - def scale(self): - return self._scale - - def _check_scale_value(self): - if self._scale > self._precision: - raise PyODataModelError('Scale value ({}) must be less than or equal to precision value ({})' - .format(self._scale, self._precision)) - - -class Schema: - class Declaration: - def __init__(self, namespace): - super(Schema.Declaration, self).__init__() - - self.namespace = namespace - - self.entity_types = dict() - self.complex_types = dict() - self.enum_types = dict() - self.entity_sets = dict() - self.function_imports = dict() - self.associations = dict() - self.association_sets = dict() - - def list_entity_types(self): - return list(self.entity_types.values()) - - def list_complex_types(self): - return list(self.complex_types.values()) - - def list_enum_types(self): - return list(self.enum_types.values()) - - def list_entity_sets(self): - return list(self.entity_sets.values()) - - def list_function_imports(self): - return list(self.function_imports.values()) - - def list_associations(self): - return list(self.associations.values()) - - def list_association_sets(self): - return list(self.association_sets.values()) - - def add_entity_type(self, etype): - """Add new type to the type repository as well as its collection variant""" - - self.entity_types[etype.name] = etype - - # automatically create and register collection variant if not exists - if isinstance(etype, NullType): - return - - collection_type_name = 'Collection({})'.format(etype.name) - self.entity_types[collection_type_name] = Collection(etype.name, etype) - - def add_complex_type(self, ctype): - """Add new complex type to the type repository as well as its collection variant""" - - self.complex_types[ctype.name] = ctype - - # automatically create and register collection variant if not exists - if isinstance(ctype, NullType): - return - - collection_type_name = 'Collection({})'.format(ctype.name) - self.complex_types[collection_type_name] = Collection(ctype.name, ctype) - - def add_enum_type(self, etype): - """Add new enum type to the type repository""" - self.enum_types[etype.name] = etype - - class Declarations(dict): - - def __getitem__(self, key): - try: - return super(Schema.Declarations, self).__getitem__(key) - except KeyError: - raise KeyError('There is no Schema Namespace {}'.format(key)) - - def __init__(self, config: Config): - super(Schema, self).__init__() - - self._decls = Schema.Declarations() - self._config = config - - def __str__(self): - return "{0}({1})".format(self.__class__.__name__, ','.join(self.namespaces)) - - @property - def namespaces(self): - return list(self._decls.keys()) - - @property - def config(self): - return self._config - - def typ(self, type_name, namespace=None): - """Returns either EntityType, ComplexType or EnumType that matches the name. - """ - - for type_space in (self.entity_type, self.complex_type, self.enum_type): - try: - return type_space(type_name, namespace=namespace) - except KeyError: - pass - - raise KeyError('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)) - - for decl in list(self._decls.values()): - try: - return decl.entity_types[type_name] - except KeyError: - pass - - raise KeyError('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)) - - for decl in list(self._decls.values()): - try: - return decl.complex_types[type_name] - except KeyError: - pass - - raise KeyError('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}') - - for decl in list(self._decls.values()): - try: - return decl.enum_types[type_name] - except KeyError: - pass - - raise KeyError(f'EnumType {type_name} does not exist in any Schema Namespace') - - def get_type(self, type_info): - - # construct search name based on collection information - search_name = type_info.name if not type_info.is_collection else 'Collection({})'.format(type_info.name) - - # first look for type in primitive types - try: - return Types.from_name(search_name) - except KeyError: - pass - - # then look for type in entity types - try: - return self.entity_type(search_name, type_info.namespace) - except KeyError: - pass - - # then look for type in complex types - try: - return self.complex_type(search_name, type_info.namespace) - except KeyError: - pass - - # then look for type in enum types - try: - return self.enum_type(search_name, type_info.namespace) - except KeyError: - pass - - raise PyODataModelError( - 'Neither primitive types nor types parsed from service metadata contain requested type {}' - .format(type_info.name)) - - @property - def entity_types(self): - return list(itertools.chain(*(decl.list_entity_types() for decl in list(self._decls.values())))) - - @property - def complex_types(self): - return list(itertools.chain(*(decl.list_complex_types() for decl in list(self._decls.values())))) - - @property - def enum_types(self): - return list(itertools.chain(*(decl.list_enum_types() for decl in list(self._decls.values())))) - - def entity_set(self, set_name, namespace=None): - if namespace is not 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)) - - for decl in list(self._decls.values()): - try: - return decl.entity_sets[set_name] - except KeyError: - pass - - raise KeyError('EntitySet {} does not exist in any Schema Namespace'.format(set_name)) - - @property - def entity_sets(self): - return list(itertools.chain(*(decl.list_entity_sets() for decl in list(self._decls.values())))) - - def function_import(self, function_import, namespace=None): - if namespace is not 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)) - - for decl in list(self._decls.values()): - try: - return decl.function_imports[function_import] - except KeyError: - pass - - raise KeyError('FunctionImport {} does not exist in any Schema Namespace'.format(function_import)) - - @property - 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: - entity_type = self.entity_type(entity_type_name, namespace) - except KeyError: - raise PyODataModelError('EntityType {} does not exist in Schema Namespace {}' - .format(entity_type_name, namespace)) - try: - entity_type.proprty(proprty) - 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): - def __init__(self, name, label, is_value_list): - super(StructType, self).__init__(name, None, EdmStructTypTraits(self), Typ.Kinds.Complex) - - self._label = label - self._is_value_list = is_value_list - self._key = list() - self._properties = dict() - - @property - def label(self): - return self._label - - @property - def is_value_list(self): - return self._is_value_list - - def proprty(self, property_name): - return self._properties[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 - - @property - def kind(self): - return Typ.Kinds.Complex - - @property - def null_value(self): - return None - - @property - def traits(self): - # return self._traits - return EdmStructTypTraits(self) - - -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 - - # 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 - - @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) - - self._key = list() - self._nav_properties = dict() - - @property - def key_proprties(self): - return list(self._key) - - @property - def nav_proprties(self): - """Gets the navigation properties defined for this entity type""" - return list(self._nav_properties.values()) - - 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): - def __init__(self, name, entity_type_info, addressable, creatable, updatable, deletable, searchable, countable, - pageable, topable, req_filter, label): - super(EntitySet, self).__init__(name) - - self._entity_type_info = entity_type_info - self._entity_type = None - self._addressable = addressable - self._creatable = creatable - self._updatable = updatable - self._deletable = deletable - self._searchable = searchable - self._countable = countable - self._pageable = pageable - self._topable = topable - self._req_filter = req_filter - self._label = label - - @property - def entity_type_info(self): - return self._entity_type_info - - @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 RuntimeError('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)) - - self._entity_type = value - - @property - def addressable(self): - return self._addressable - - @property - def creatable(self): - return self._creatable - - @property - def updatable(self): - return self._updatable - - @property - def deletable(self): - return self._deletable - - @property - def searchable(self): - return self._searchable - - @property - def countable(self): - return self._countable - - @property - def pageable(self): - return self._pageable - - @property - def topable(self): - return self._topable - - @property - def requires_filter(self): - return self._req_filter - - @property - 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): - """Property of structure types (Entity/Complex type) - - Type of the property can be: - * primitive type - * complex type - * enumeration type (in version 4) - * collection of one of previous - """ - - # pylint: disable=too-many-locals - def __init__(self, name, type_info, nullable, max_length, precision, scale, uncode, label, creatable, updatable, - sortable, filterable, filter_restr, req_in_filter, text, visible, display_format, value_list): - super(StructTypeProperty, self).__init__(name, type_info, nullable, max_length, precision, scale) - - self._value_helper = None - self._struct_type = None - self._uncode = uncode - self._label = label - self._creatable = creatable - self._updatable = updatable - self._sortable = sortable - self._filterable = filterable - self._filter_restr = filter_restr - self._req_in_filter = req_in_filter - self._text_proprty_name = text - self._visible = visible - self._display_format = display_format - self._value_list = value_list - - # Lazy loading - self._text_proprty = None - - @property - def struct_type(self): - return self._struct_type - - @struct_type.setter - 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)) - - self._struct_type = value - - if self._text_proprty_name is not None: - try: - self._text_proprty = self._struct_type.proprty(self._text_proprty_name) - 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)) - - @property - def text_proprty_name(self): - return self._text_proprty_name - - @property - def text_proprty(self): - return self._text_proprty - - @property - def uncode(self): - return self._uncode - - @property - def label(self): - return self._label - - @property - def creatable(self): - return self._creatable - - @property - def updatable(self): - return self._updatable - - @property - def sortable(self): - return self._sortable - - @property - def filterable(self): - return self._filterable - - @property - def filter_restriction(self): - return self._filter_restr - - @property - def required_in_filter(self): - return self._req_in_filter - - @property - def visible(self): - return self._visible - - @property - def upper_case(self): - return self._display_format == 'UpperCase' - - @property - def date(self): - return self._display_format == 'Date' - - @property - def non_negative(self): - return self._display_format == 'NonNegative' - - @property - def value_helper(self): - return self._value_helper - - @property - def value_list(self): - return self._value_list - - @value_helper.setter - 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)) - - 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): - """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 - - @staticmethod - def from_etree(node): - - return NavigationTypeProperty( - node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship'))) - - -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 - - @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): - 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 - - @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: - """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 - - @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: - 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 - - @staticmethod - def from_etree(end_node): - role = end_node.get('Role') - entity_set = end_node.get('EntitySet') - - return AssociationSetEndRole(role, entity_set) - - -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 - - @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: - Kinds = Enum('Kinds', 'ValueHelper') - - def __init__(self, kind, 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) - - @property - def element_namespace(self): - return self._element_namespace - - @property - def element(self): - return self._element - - @property - def target(self): - return '{0}.{1}'.format(self._element_namespace, self._element) - - @property - 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 ValueHelper(Annotation): - def __init__(self, target, collection_path, label, search_supported): - - # pylint: disable=unused-argument - - super(ValueHelper, self).__init__(Annotation.Kinds.ValueHelper, target) - - self._entity_type_name, self._proprty_name = self.element.split('/') - self._proprty = None - - self._collection_path = collection_path - self._entity_set = None - - self._label = label - self._parameters = list() - - def __str__(self): - return "{0}({1})".format(self.__class__.__name__, self.element) - - @property - def proprty_name(self): - return self._proprty_name - - @property - def proprty_entity_type_name(self): - return self._entity_type_name - - @property - def proprty(self): - return self._proprty - - @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)) - - 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)) - - self._proprty = value - - for param in self._parameters: - if param.local_property_name: - 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( - param, self, param.local_property_name, etype)) - - @property - def collection_path(self): - return self._collection_path - - @property - def entity_set(self): - return self._entity_set - - @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)) - - if value.name != self.collection_path: - raise RuntimeError('{0} cannot be assigned to {1}'.format(self, value)) - - self._entity_set = value - - for param in self._parameters: - if param.list_property_name: - 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( - param, self, param.list_property_name, etype)) - - @property - def label(self): - return self._label - - @property - def parameters(self): - return self._parameters - - def local_property_param(self, name): - for prm in self._parameters: - if prm.local_property.name == name: - return prm - - raise KeyError('{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)) - - @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: - Direction = Enum('Direction', 'In InOut Out DisplayOnly FilterOnly') - - def __init__(self, direction, local_property_name, list_property_name): - super(ValueHelperParameter, self).__init__() - - self._direction = direction - self._value_helper = None - - self._local_property = None - self._local_property_name = local_property_name - - self._list_property = None - self._list_property_name = list_property_name - - def __str__(self): - if self._direction in [ValueHelperParameter.Direction.DisplayOnly, ValueHelperParameter.Direction.FilterOnly]: - return "{0}({1})".format(self.__class__.__name__, self._list_property_name) - - return "{0}({1}={2})".format(self.__class__.__name__, self._local_property_name, self._list_property_name) - - @property - def value_helper(self): - return self._value_helper - - @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)) - - self._value_helper = value - - @property - def direction(self): - return self._direction - - @property - def local_property_name(self): - return self._local_property_name - - @property - def local_property(self): - return self._local_property - - @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)) - - self._local_property = value - - @property - def list_property_name(self): - return self._list_property_name - - @property - def list_property(self): - return self._list_property - - @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)) - - 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): - def __init__(self, name, return_type_info, entity_set, parameters, http_method='GET'): - super(FunctionImport, self).__init__(name) - - self._entity_set_name = entity_set - self._return_type_info = return_type_info - self._return_type = None - self._parameters = parameters - self._http_method = http_method - - @property - def return_type_info(self): - return self._return_type_info - - @property - def return_type(self): - return self._return_type - - @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)) - - if value.name != self.return_type_info[1]: - raise RuntimeError('{0} cannot be the type of {1}'.format(value, self)) - - self._return_type = value - - @property - def entity_set_name(self): - return self._entity_set_name - - @property - def parameters(self): - return list(self._parameters.values()) - - def get_parameter(self, parameter): - return self._parameters[parameter] - - @property - 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') - - def __init__(self, name, type_info, nullable, max_length, precision, scale, mode): - super(FunctionImportParameter, self).__init__(name, type_info, nullable, max_length, precision, scale) - - self._mode = mode - - @property - def mode(self): - return self._mode - - -def sap_attribute_get(node, attr): - return node.get('{http://www.sap.com/Protocols/SAPData}%s' % (attr)) - - -def metadata_attribute_get(node, attr): - return node.get('{http://schemas.microsoft.com/ado/2007/08/dataservices/metadata}%s' % (attr)) - - -def sap_attribute_get_string(node, attr): - return sap_attribute_get(node, attr) - - -def sap_attribute_get_bool(node, attr, default): - value = sap_attribute_get(node, attr) - if value is None: - return default - - if value == 'true': - return True - - if value == 'false': - 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/v2/service.py b/pyodata/v2/service.py index 95a27dcf..a93e9871 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -13,25 +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.exceptions import HttpError, PyODataException, ExpressionError -from . import model +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('') @@ -54,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 @@ -73,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 = [] @@ -99,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 @@ -114,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( @@ -133,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 @@ -157,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: @@ -192,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: @@ -215,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 @@ -235,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 @@ -308,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 @@ -331,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 @@ -339,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: @@ -356,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: @@ -380,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) @@ -395,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() @@ -414,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. """ @@ -436,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. @@ -455,12 +466,12 @@ def _build_values(entity_type, entity): values = {} for key, val in entity.items(): try: - val = entity_type.proprty(key).typ.traits.to_json(val) - except KeyError: + 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 KeyError: + except PyODataModelError: raise PyODataException('Property {} is not declared in {} entity type'.format( key, entity_type.name)) @@ -468,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) @@ -482,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 @@ -490,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' @@ -504,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) @@ -557,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 @@ -599,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 {} @@ -633,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: @@ -663,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)) @@ -685,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', } @@ -702,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) @@ -717,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]) @@ -736,8 +753,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_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 +762,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_v2.EndRole.MULTIPLICITY_ZERO_OR_MORE: # default value is empty array self._cache[prop.name] = [] @@ -775,10 +792,14 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key= except PyODataException: pass - def __repr__(self): - return self._entity_key.to_key_string() + def __repr__(self) -> str: + 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): + def __getattr__(self, attr: str) -> Any: try: return self._cache[attr] except KeyError: @@ -790,51 +811,51 @@ 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)) 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_v2.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), + 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 str(self._entity_set._name + self._entity_key.to_key_string()) # pylint: disable=protected-access - 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: @@ -850,10 +871,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: @@ -868,19 +889,19 @@ def value_get_handler(key, response): connection=connection) @property - def entity_set(self): + def entity_set(self) -> Optional[Union['EntitySet', 'EntitySetProxy']]: """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('/') @@ -888,7 +909,7 @@ def url(self): return urljoin(service_url, entity_path) - def equals(self, other): + 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 @@ -897,14 +918,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) @@ -913,11 +934,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: @@ -926,39 +947,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) @@ -966,7 +988,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 @@ -989,18 +1012,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: @@ -1024,7 +1047,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_v2.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)): return self._get_nav_entity(key, nav_property, navigation_entity_set) return EntitySetProxy( @@ -1033,10 +1056,12 @@ 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: EntityProxy, nav_property: str, navigation_entity_set: EntitySet, + response: requests.Response) -> NavEntityProxy: """Gets entity from HTTP response""" if response.status_code != requests.codes.ok: @@ -1061,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: @@ -1087,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: @@ -1112,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: @@ -1129,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: @@ -1149,10 +1174,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: @@ -1173,15 +1198,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: @@ -1195,23 +1220,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: @@ -1260,8 +1286,8 @@ 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): - entity_set = self._service.schema.entity_set(fimport.entity_set_name) + 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.) @@ -1274,7 +1300,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 @@ -1282,36 +1308,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 @@ -1320,7 +1346,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) -> ODataHttpRequest: """HTTP GET request proxy for the passed path in the service""" conn = connection @@ -1333,15 +1360,15 @@ 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) - 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) @@ -1361,12 +1388,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]) -> List[ODataHttpResponse]: """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 @@ -1399,10 +1426,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 @@ -1413,28 +1441,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 @@ -1452,14 +1480,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' @@ -1467,5 +1495,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 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..82666fea --- /dev/null +++ b/pyodata/v4/__init__.py @@ -0,0 +1,80 @@ +""" This module represents implementation of ODATA V4 """ + + +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 +from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits + +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 +from .service import Service # noqa + + +class ODataV4(ODATAVersion): + """ Definition of OData V4 """ + + @staticmethod + def build_functions() -> BuildFunctionDict: + return { + 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 + def primitive_types() -> PrimitiveTypeList: + # 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()), + ] + + @staticmethod + def annotations() -> BuildAnnotationDict: + return { + Unit: build_unit_annotation + } diff --git a/pyodata/v4/build_functions.py b/pyodata/v4/build_functions.py new file mode 100644 index 00000000..1e216355 --- /dev/null +++ b/pyodata/v4/build_functions.py @@ -0,0 +1,260 @@ +""" Repository of build functions specific to the ODATA V4""" + +# 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.build_functions import build_entity_set +from pyodata.model.elements import ComplexType, Schema, NullType, build_element, EntityType, Types, \ + StructTypeProperty, build_annotation, Typ, Identifier +from pyodata.policies import ParserError +from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint, \ + NavigationPropertyBinding, EntitySet, Unit, EnumMember, EnumType + + +# 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. + + # 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 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): + 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): + 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): + 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): + 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) + 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) + referenced_proprty = NullProperty(ref_con.referenced_proprty_name) + + ref_con.proprty = proprty + ref_con.referenced_proprty = referenced_proprty + + # 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 (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 + entity_set: EntitySet + for entity_set in schema.entity_sets: + nav_prop_bin: NavigationPropertyBinding + for nav_prop_bin in entity_set.navigation_property_bindings: + try: + 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(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. + 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) + + +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(Identifier.parse(node.get('Path')), Identifier.parse(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): + 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 PyODataModelError as ex: + config.err_policy(ParserError.TYPE_DEFINITION).resolve(ex) + typ = NullType(node.get('Name')) + + 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: + 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 + except (PyODataParserError, AttributeError) as ex: + config.err_policy(ParserError.ENUM_TYPE).resolve(ex) + return NullType(type_node.get('Name')) diff --git a/pyodata/v4/elements.py b/pyodata/v4/elements.py new file mode 100644 index 00000000..f03d2f97 --- /dev/null +++ b/pyodata/v4/elements.py @@ -0,0 +1,240 @@ +""" Repository of elements specific to the ODATA V4""" +from typing import Optional, List + +from pyodata.model import elements +from pyodata.exceptions import PyODataModelError, PyODataException +from pyodata.model.elements import VariableDeclaration, StructType, Annotation, Identifier, IdentifierInfo +from pyodata.model.type_traits import TypTraits +from pyodata.v4.type_traits import EnumTypTrait + + +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 + + +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: [IdentifierInfo], 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) -> [IdentifierInfo]: + 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 + + +# 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 + """ + 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 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): + 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/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/v4/type_traits.py b/pyodata/v4/type_traits.py new file mode 100644 index 00000000..97d7d3ec --- /dev/null +++ b/pyodata/v4/type_traits.py @@ -0,0 +1,284 @@ +""" 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' +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 + +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""" + + 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) + + 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: + 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) + + +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/pyodata/version.py b/pyodata/version.py new file mode 100644 index 00000000..b1b58b3f --- /dev/null +++ b/pyodata/version.py @@ -0,0 +1,44 @@ +""" Base class for defining ODATA versions. """ + +from abc import ABC, abstractmethod +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. + 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: PrimitiveTypeDict = dict() + + @staticmethod + @abstractmethod + def primitive_types() -> PrimitiveTypeList: + """ Here we define which primitive types are supported and what is their python representation""" + + @staticmethod + @abstractmethod + def build_functions() -> BuildFunctionDict: + """ Here we define which elements are supported and what is their python representation""" + + @staticmethod + @abstractmethod + def annotations() -> BuildAnnotationDict: + """ 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/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 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", diff --git a/tests/conftest.py b/tests/conftest.py index ddd6f723..a6830f52 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,126 +1,36 @@ """PyTest Fixtures""" -import logging import os +from typing import Type import pytest -from pyodata.v2.model import schema_from_xml +import jinja2 +from pyodata.config import Config +from pyodata.version import ODATAVersion +from pyodata.model.builder import MetadataBuilder +from pyodata.v4 import ODataV4 +from pyodata.v2 import ODataV2 -@pytest.fixture -def metadata(): - """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, "metadata.xml") - - 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 + return os.path.join(current_directory, file_name) @pytest.fixture -def schema(metadata): - """Parsed metadata""" - - # pylint: disable=redefined-outer-name - - return schema_from_xml(metadata) - - -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/test_base.py b/tests/test_base.py new file mode 100644 index 00000000..4a0a6d87 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,94 @@ +from typing import List +import pytest + +from pyodata.config import Config +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, build_element +from pyodata.v2 import ODataV2 + + +def test_build_element(): + """Test FromEtreeMixin class""" + + class EmptyODATA(ODATAVersion): + @staticmethod + def build_functions(): + return {} + + config = Config(EmptyODATA) + + class TestElement: + pass + + with pytest.raises(PyODataParserError) as typ_ex_info: + build_element(TestElement, config) + + assert typ_ex_info.value.args[0] == f'{TestElement.__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 primitive_types() -> List[Typ]: + return [ + Typ('Edm.Binary', 'binary\'\'') + ] + + config = Config(EmptyODATA) + with pytest.raises(PyODataModelError) as typ_ex_info: + Types.from_name('UnsupportedType', config) + + 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' + + +def test_odata_version_statelessness(): + + class EmptyODATA(ODATAVersion): + @staticmethod + def build_functions(): + return {} + + @staticmethod + def primitive_types() -> List[Typ]: + return [] + + @staticmethod + def annotations(): + pass + + 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' + + +def test_types_repository_separation(): + ODataV2.Types = dict() + + class TestODATA(ODATAVersion): + @staticmethod + def primitive_types() -> List['Typ']: + return [ + Typ('PrimitiveType', '0') + ] + + config_test = Config(TestODATA) + config_v2 = Config(ODataV2) + + 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 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 97% rename from tests/metadata.xml rename to tests/v2/metadata.xml index da460388..3df74fb4 100644 --- a/tests/metadata.xml +++ b/tests/v2/metadata.xml @@ -114,23 +114,6 @@ - - - - - - - - - - - - - - - - - @@ -201,7 +184,6 @@ sap:topable="true"/> - diff --git a/tests/test_client.py b/tests/v2/test_client.py similarity index 75% rename from tests/test_client.py rename to tests/v2/test_client.py index 4dcbdbbb..14cd1414 100644 --- a/tests/test_client.py +++ b/tests/v2/test_client.py @@ -1,32 +1,27 @@ """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 -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) @@ -34,47 +29,47 @@ 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) - 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 -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) - 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 @@ -111,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 = { @@ -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_v2.py b/tests/v2/test_model.py similarity index 86% rename from tests/test_model_v2.py rename to tests/v2/test_model.py index d458a9f8..250a358e 100644 --- a/tests/test_model_v2.py +++ b/tests/v2/test_model.py @@ -2,13 +2,18 @@ # 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 pyodata.v2.model import Schema, Typ, StructTypeProperty, Types, EntityType, EdmStructTypeSerializer, \ - Association, AssociationSet, EndRole, AssociationSetEndRole, TypeInfo, MetadataBuilder, ParserError, PolicyWarning, \ - PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation + +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 +from pyodata.model.type_traits import EdmStructTypeSerializer +from pyodata.policies import ParserError, PolicyWarning, PolicyIgnore, PolicyFatal from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError -from tests.conftest import assert_logging_policy +from pyodata.v2 import ODataV2 +from pyodata.v2.elements import EndRole, AssociationSet, AssociationSetEndRole, Association, NullAssociation def test_edmx(schema): @@ -30,7 +35,6 @@ def test_edmx(schema): 'CarIDPic', 'Customer', 'Order', - 'EnumTest' } assert set((entity_set.name for entity_set in schema.entity_sets)) == { @@ -46,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') @@ -138,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' @@ -156,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' @@ -236,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' @@ -350,20 +348,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 +376,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 +399,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 +411,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 +502,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 +549,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 @@ -619,11 +625,11 @@ 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: + 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() @@ -631,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' ) @@ -671,13 +677,11 @@ 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: + 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() @@ -685,12 +689,12 @@ 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' ) -@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""" @@ -731,9 +735,9 @@ 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: + 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' @@ -753,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): @@ -802,7 +805,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') @@ -843,6 +846,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() } @@ -865,8 +869,6 @@ def test_null_type(xml_builder_factory): - - @@ -880,6 +882,7 @@ def test_null_type(xml_builder_factory): metadata = MetadataBuilder( xml_builder.serialize(), config=Config( + ODataV2, default_error_policy=PolicyIgnore() )) @@ -888,8 +891,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) @@ -927,6 +928,7 @@ def test_faulty_association(xml_builder_factory): metadata = MetadataBuilder( xml_builder.serialize(), config=Config( + ODataV2, default_error_policy=PolicyIgnore() )) @@ -953,6 +955,7 @@ def test_faulty_association_set(xml_builder_factory): metadata = MetadataBuilder( xml_builder.serialize(), config=Config( + ODataV2, default_error_policy=PolicyWarning() )) @@ -976,9 +979,9 @@ 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: + 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' @@ -996,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' @@ -1033,7 +1036,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' @@ -1046,13 +1049,13 @@ 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' -@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() @@ -1061,13 +1064,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, Config(ODataV2)).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() @@ -1076,26 +1077,26 @@ 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, xml_namespaces={'edmx': edmx} ) ).build() - assert Schema.from_etree is mock_from_etree - mock_from_etree.assert_called_once() + assert schema == 'Mocked' try: - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).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() @@ -1104,26 +1105,25 @@ 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, xml_namespaces={'edm': edm} ) ).build() - assert Schema.from_etree is mock_from_etree - mock_from_etree.assert_called_once() + assert schema == 'Mocked' try: - - MetadataBuilder(xml).build() + MetadataBuilder(xml, Config(ODataV2)).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""" @@ -1132,13 +1132,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, Config(ODataV2)).build() == 'Mocked' -@patch.object(Schema, 'from_etree') -def test_whitelisted_edm_namespace_2006_04(mock_from_etree, xml_builder_factory): +@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""" xml_builder = xml_builder_factory() @@ -1146,13 +1144,12 @@ def test_whitelisted_edm_namespace_2006_04(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() + MetadataBuilder(xml, Config(ODataV2)).build() + 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_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() @@ -1160,80 +1157,8 @@ def test_whitelisted_edm_namespace_2007_05(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() - - -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 PyODataParserError as ex: - assert str(ex) == f'Value -130 is out of range for type Edm.Byte' + MetadataBuilder(xml, Config(ODataV2)).build() + mocked.assert_called_once() @patch('logging.Logger.warning') @@ -1279,18 +1204,19 @@ 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: - MetadataBuilder(xml).build() + 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 ' \ 'an non existing LocalDataProperty --- of EntityType(MasterEntity)' MetadataBuilder(xml, Config( + ODataV2, default_error_policy=PolicyWarning() )).build() assert_logging_policy(mock_warning, - 'RuntimeError', + 'PyODataModelError', 'ValueHelperParameter(Type) of ValueHelper(MasterEntity/Data) points to ' 'an non existing LocalDataProperty --- of EntityType(MasterEntity)' ) @@ -1300,18 +1226,19 @@ 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: - MetadataBuilder(xml).build() + 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, default_error_policy=PolicyWarning() )).build() assert_logging_policy(mock_warning, - 'RuntimeError', + 'PyODataModelError', 'ValueHelperParameter(---) of ValueHelper(MasterEntity/Data) points to an non ' 'existing ValueListProperty --- of EntityType(DataEntity)' ) @@ -1324,6 +1251,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/v2/test_service.py similarity index 97% rename from tests/test_service_v2.py rename to tests/v2/test_service.py index 9bdef81c..67035761 100644 --- a/tests/test_service_v2.py +++ b/tests/v2/test_service.py @@ -6,12 +6,14 @@ import pytest from unittest.mock import patch -import pyodata.v2.model +# from typeguard.importhook import install_import_hook +# install_import_hook('pyodata.v2') + 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 +from tests.v2.conftest import assert_request_contains_header URL_ROOT = 'http://odatapy.example.com' @@ -87,32 +89,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""" @@ -1350,9 +1326,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 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..d55b2c83 --- /dev/null +++ b/tests/v4/test_build_functions.py @@ -0,0 +1,150 @@ +import pytest +from lxml import etree + +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 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 == 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 == [ + IdentifierInfo('SampleService', 'Flight'), + IdentifierInfo(None, 'Airline')] + assert navigation_property_binding.target_info == IdentifierInfo(None, '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 == IdentifierInfo(None, '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_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' 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