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.
+
+
+
+## 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