Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 3515a36

Browse files
committedNov 7, 2019
Splits python representation of metadata and its parser
The current implementation of python representation of metadata and metadata parser was tightly interconnected. Support for other versions of OData was not possible as in each version elements are added, removed or modified. Therefore, we decided to split metadata representation and its parser. With this approach, we can easily define supported elements and its parsing functions in a single class. This "configuration" class is stateless and has to be a child of ODATAVersion. Additional changes including updating directory structure and refactoring old code to accommodate for incoming ODATA V4 support. New module model: - builder -> MetadataBuilder was moved here to make code easier to read elements -> All EDM elements were moved here, to make python representation of elements version independent. All parsable elements have to inherit from "from_etree_mixin". - from_etree_callbacks -> All from_etree static methods were moved into separated function. This is a naive approach as its premise is that all from_etree implementations will be reusable in version V4. - types_traits -> "types traits" were moved here to make code cleaner and easier to read. Module V2: - __init__ -> includes OData2 definition. - service -> function-wise nothing has been changed. "Main" module: - config -> class Config was moved here to make it version and model-independent. In case we will ever need a config class also for service. Also ODataVersion class lives here. - policies -> All policies were moved here as well as ParserError enum. Again to make policies version and model-independent. Tests were only updated to incorporate new API.
1 parent 05243e4 commit 3515a36

18 files changed

+1444
-1161
lines changed
 

‎CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
99
### Added
1010
- Client can be created from local metadata - Jakub Filak
1111
- support all standard EDM schema versions - Jakub Filak
12+
- Splits python representation of metadata and metadata parsing - Martin Miksik
1213

1314
### Fixed
1415
- make sure configured error policies are applied for Annotations referencing

‎docs/usage/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
The User Guide
1+
versionThe User Guide
22
--------------
33

44
* [Initialization](initialization.rst)

‎docs/usage/initialization.rst

+4-1
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ For parser to use your custom configuration, it needs to be passed as an argumen
121121
.. code-block:: python
122122
123123
import pyodata
124-
from pyodata.v2.model import PolicyFatal, PolicyWarning, PolicyIgnore, ParserError, Config
124+
from pyodata.v2 import ODataV2
125+
from pyodata.policies import PolicyFatal, PolicyWarning, PolicyIgnore, ParserError
126+
from pyodata.config import Config
125127
import requests
126128
127129
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
132134
}
133135
134136
custom_config = Config(
137+
ODataV2,
135138
xml_namespaces=namespaces,
136139
default_error_policy=PolicyFatal(),
137140
custom_error_policies={

‎pyodata/client.py

+27-31
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import logging
44
import warnings
55

6-
import pyodata.v2.model
7-
import pyodata.v2.service
6+
from pyodata.config import Config
7+
from pyodata.model.builder import MetadataBuilder
88
from pyodata.exceptions import PyODataException, HttpError
9+
from pyodata.v2.service import Service
10+
from pyodata.v2 import ODataV2
911

1012

1113
def _fetch_metadata(connection, url, logger):
@@ -34,43 +36,37 @@ class Client:
3436
"""OData service client"""
3537

3638
# pylint: disable=too-few-public-methods
37-
38-
ODATA_VERSION_2 = 2
39-
40-
def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
41-
config: pyodata.v2.model.Config = None, metadata: str = None):
39+
def __new__(cls, url, connection, namespaces=None,
40+
config: Config = None, metadata: str = None):
4241
"""Create instance of the OData Client for given URL"""
4342

4443
logger = logging.getLogger('pyodata.client')
4544

46-
if odata_version == Client.ODATA_VERSION_2:
47-
48-
# sanitize url
49-
url = url.rstrip('/') + '/'
50-
51-
if metadata is None:
52-
metadata = _fetch_metadata(connection, url, logger)
53-
else:
54-
logger.info('Using static metadata')
45+
# sanitize url
46+
url = url.rstrip('/') + '/'
5547

56-
if config is not None and namespaces is not None:
57-
raise PyODataException('You cannot pass namespaces and config at the same time')
48+
if metadata is None:
49+
metadata = _fetch_metadata(connection, url, logger)
50+
else:
51+
logger.info('Using static metadata')
5852

59-
if config is None:
60-
config = pyodata.v2.model.Config()
53+
if config is not None and namespaces is not None:
54+
raise PyODataException('You cannot pass namespaces and config at the same time')
6155

62-
if namespaces is not None:
63-
warnings.warn("Passing namespaces directly is deprecated. Use class Config instead", DeprecationWarning)
64-
config.namespaces = namespaces
56+
if config is None:
57+
logger.info('No OData version has been provided. Client defaulted to OData v2')
58+
config = Config(ODataV2)
6559

66-
# create model instance from received metadata
67-
logger.info('Creating OData Schema (version: %d)', odata_version)
68-
schema = pyodata.v2.model.MetadataBuilder(metadata, config=config).build()
60+
if namespaces is not None:
61+
warnings.warn("Passing namespaces directly is deprecated. Use class Config instead", DeprecationWarning)
62+
config.namespaces = namespaces
6963

70-
# create service instance based on model we have
71-
logger.info('Creating OData Service (version: %d)', odata_version)
72-
service = pyodata.v2.service.Service(url, schema, connection)
64+
# create model instance from received metadata
65+
logger.info('Creating OData Schema (version: %d)', config.odata_version)
66+
schema = MetadataBuilder(metadata, config=config).build()
7367

74-
return service
68+
# create service instance based on model we have
69+
logger.info('Creating OData Service (version: %d)', config.odata_version)
70+
service = Service(url, schema, connection)
7571

76-
raise PyODataException('No implementation for selected odata version {}'.format(odata_version))
72+
return service

‎pyodata/config.py

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
""" Contains definition of configuration class for PyOData and for ODATA versions. """
2+
3+
from abc import ABC, abstractmethod
4+
from typing import Type, List, Dict, Callable
5+
6+
from pyodata.policies import PolicyFatal, ParserError, ErrorPolicy
7+
8+
9+
class ODATAVersion(ABC):
10+
""" This is base class for different OData releases. In it we define what are supported types, elements and so on.
11+
Furthermore, we specify how individual elements are parsed or represented by python objects.
12+
"""
13+
14+
def __init__(self):
15+
raise RuntimeError('ODATAVersion and its children are intentionally stateless, '
16+
'therefore you can not create instance of them')
17+
18+
@staticmethod
19+
@abstractmethod
20+
def supported_primitive_types() -> List[str]:
21+
""" Here we define which primitive types are supported and what is their python representation"""
22+
23+
@staticmethod
24+
@abstractmethod
25+
def from_etree_callbacks() -> Dict[object, Callable]:
26+
""" Here we define which elements are supported and what is their python representation"""
27+
28+
@classmethod
29+
def is_primitive_type_supported(cls, type_name):
30+
""" Convenience method which decides whatever given type is supported."""
31+
return type_name in cls.supported_primitive_types()
32+
33+
34+
class Config:
35+
# pylint: disable=too-many-instance-attributes,missing-docstring
36+
# All attributes have purpose and are used for configuration
37+
# Having docstring for properties is not necessary as we do have type hints
38+
39+
""" This is configuration class for PyOData. All session dependent settings should be stored here. """
40+
41+
def __init__(self,
42+
odata_version: Type[ODATAVersion],
43+
custom_error_policies=None,
44+
default_error_policy=None,
45+
xml_namespaces=None
46+
):
47+
48+
"""
49+
:param custom_error_policies: {ParserError: ErrorPolicy} (default None)
50+
Used to specified individual policies for XML tags. See documentation for more
51+
details.
52+
53+
:param default_error_policy: ErrorPolicy (default PolicyFatal)
54+
If custom policy is not specified for the tag, the default policy will be used.
55+
56+
:param xml_namespaces: {str: str} (default None)
57+
"""
58+
59+
self._custom_error_policy = custom_error_policies
60+
61+
if default_error_policy is None:
62+
default_error_policy = PolicyFatal()
63+
64+
self._default_error_policy = default_error_policy
65+
66+
if xml_namespaces is None:
67+
xml_namespaces = {}
68+
69+
self._namespaces = xml_namespaces
70+
71+
self._odata_version = odata_version
72+
73+
self._sap_value_helper_directions = None
74+
self._sap_annotation_value_list = None
75+
self._annotation_namespaces = None
76+
77+
def err_policy(self, error: ParserError) -> ErrorPolicy:
78+
""" Returns error policy for given error. If custom error policy fo error is set, then returns that."""
79+
if self._custom_error_policy is None:
80+
return self._default_error_policy
81+
82+
return self._custom_error_policy.get(error, self._default_error_policy)
83+
84+
def set_default_error_policy(self, policy: ErrorPolicy):
85+
""" Sets default error policy as well as resets custom error policies"""
86+
self._custom_error_policy = None
87+
self._default_error_policy = policy
88+
89+
def set_custom_error_policy(self, policies: Dict[ParserError, Type[ErrorPolicy]]):
90+
""" Sets custom error policy. It should be called only after setting default error policy, otherwise
91+
it has no effect. See implementation of "set_default_error_policy" for more details.
92+
"""
93+
self._custom_error_policy = policies
94+
95+
@property
96+
def namespaces(self) -> str:
97+
return self._namespaces
98+
99+
@namespaces.setter
100+
def namespaces(self, value: Dict[str, str]):
101+
self._namespaces = value
102+
103+
@property
104+
def odata_version(self) -> Type[ODATAVersion]:
105+
return self._odata_version
106+
107+
@property
108+
def sap_value_helper_directions(self):
109+
return self._sap_value_helper_directions
110+
111+
@property
112+
def sap_annotation_value_list(self):
113+
return self._sap_annotation_value_list
114+
115+
@property
116+
def annotation_namespace(self):
117+
return self._annotation_namespaces

‎pyodata/model/__init__.py

Whitespace-only changes.

‎pyodata/model/builder.py

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Metadata Builder Implementation"""
2+
3+
import collections
4+
import io
5+
from lxml import etree
6+
7+
from pyodata.config import Config
8+
from pyodata.exceptions import PyODataParserError
9+
from pyodata.model.elements import ValueHelperParameter, Schema
10+
import pyodata.v2 as v2
11+
12+
13+
ANNOTATION_NAMESPACES = {
14+
'edm': 'http://docs.oasis-open.org/odata/ns/edm',
15+
'edmx': 'http://docs.oasis-open.org/odata/ns/edmx'
16+
}
17+
18+
SAP_VALUE_HELPER_DIRECTIONS = {
19+
'com.sap.vocabularies.Common.v1.ValueListParameterIn': ValueHelperParameter.Direction.In,
20+
'com.sap.vocabularies.Common.v1.ValueListParameterInOut': ValueHelperParameter.Direction.InOut,
21+
'com.sap.vocabularies.Common.v1.ValueListParameterOut': ValueHelperParameter.Direction.Out,
22+
'com.sap.vocabularies.Common.v1.ValueListParameterDisplayOnly': ValueHelperParameter.Direction.DisplayOnly,
23+
'com.sap.vocabularies.Common.v1.ValueListParameterFilterOnly': ValueHelperParameter.Direction.FilterOnly
24+
}
25+
26+
27+
SAP_ANNOTATION_VALUE_LIST = ['com.sap.vocabularies.Common.v1.ValueList']
28+
29+
30+
# pylint: disable=protected-access
31+
class MetadataBuilder:
32+
"""Metadata builder"""
33+
34+
EDMX_WHITELIST = [
35+
'http://schemas.microsoft.com/ado/2007/06/edmx',
36+
'http://docs.oasis-open.org/odata/ns/edmx',
37+
]
38+
39+
EDM_WHITELIST = [
40+
'http://schemas.microsoft.com/ado/2006/04/edm',
41+
'http://schemas.microsoft.com/ado/2007/05/edm',
42+
'http://schemas.microsoft.com/ado/2008/09/edm',
43+
'http://schemas.microsoft.com/ado/2009/11/edm',
44+
'http://docs.oasis-open.org/odata/ns/edm'
45+
]
46+
47+
def __init__(self, xml, config=None):
48+
self._xml = xml
49+
50+
if config is None:
51+
config = Config(v2.ODataV2)
52+
self._config = config
53+
54+
# pylint: disable=missing-docstring
55+
@property
56+
def config(self) -> Config:
57+
return self._config
58+
59+
def build(self):
60+
""" Build model from the XML metadata"""
61+
62+
if isinstance(self._xml, str):
63+
mdf = io.StringIO(self._xml)
64+
elif isinstance(self._xml, bytes):
65+
mdf = io.BytesIO(self._xml)
66+
else:
67+
raise TypeError('Expected bytes or str type on metadata_xml, got : {0}'.format(type(self._xml)))
68+
69+
namespaces = self._config.namespaces
70+
xml = etree.parse(mdf)
71+
edmx = xml.getroot()
72+
73+
try:
74+
dataservices = next((child for child in edmx if etree.QName(child.tag).localname == 'DataServices'))
75+
except StopIteration:
76+
raise PyODataParserError('Metadata document is missing the element DataServices')
77+
78+
try:
79+
schema = next((child for child in dataservices if etree.QName(child.tag).localname == 'Schema'))
80+
except StopIteration:
81+
raise PyODataParserError('Metadata document is missing the element Schema')
82+
83+
if 'edmx' not in self._config.namespaces:
84+
namespace = etree.QName(edmx.tag).namespace
85+
86+
if namespace not in self.EDMX_WHITELIST:
87+
raise PyODataParserError(f'Unsupported Edmx namespace - {namespace}')
88+
89+
namespaces['edmx'] = namespace
90+
91+
if 'edm' not in self._config.namespaces:
92+
namespace = etree.QName(schema.tag).namespace
93+
94+
if namespace not in self.EDM_WHITELIST:
95+
raise PyODataParserError(f'Unsupported Schema namespace - {namespace}')
96+
97+
namespaces['edm'] = namespace
98+
99+
self._config.namespaces = namespaces
100+
101+
self._config._sap_value_helper_directions = SAP_VALUE_HELPER_DIRECTIONS
102+
self._config._sap_annotation_value_list = SAP_ANNOTATION_VALUE_LIST
103+
self._config._annotation_namespaces = ANNOTATION_NAMESPACES
104+
105+
self.update_alias(self.get_aliases(xml, self._config), self._config)
106+
107+
edm_schemas = xml.xpath('/edmx:Edmx/edmx:DataServices/edm:Schema', namespaces=self._config.namespaces)
108+
return Schema.from_etree(edm_schemas, self._config)
109+
110+
@staticmethod
111+
def get_aliases(edmx, config: Config):
112+
"""Get all aliases"""
113+
114+
aliases = collections.defaultdict(set)
115+
edm_root = edmx.xpath('/edmx:Edmx', namespaces=config.namespaces)
116+
if edm_root:
117+
edm_ref_includes = edm_root[0].xpath('edmx:Reference/edmx:Include', namespaces=config.annotation_namespace)
118+
for ref_incl in edm_ref_includes:
119+
namespace = ref_incl.get('Namespace')
120+
alias = ref_incl.get('Alias')
121+
if namespace is not None and alias is not None:
122+
aliases[namespace].add(alias)
123+
124+
return aliases
125+
126+
@staticmethod
127+
def update_alias(aliases, config: Config):
128+
"""Update config with aliases"""
129+
130+
namespace, suffix = config.sap_annotation_value_list[0].rsplit('.', 1)
131+
config._sap_annotation_value_list.extend([alias + '.' + suffix for alias in aliases[namespace]])
132+
133+
helper_direction_keys = list(config.sap_value_helper_directions.keys())
134+
for direction_key in helper_direction_keys:
135+
namespace, suffix = direction_key.rsplit('.', 1)
136+
for alias in aliases[namespace]:
137+
config._sap_value_helper_directions[alias + '.' + suffix] = \
138+
config.sap_value_helper_directions[direction_key]
139+
140+
141+
def schema_from_xml(metadata_xml, namespaces=None):
142+
"""Parses XML data and returns Schema representing OData Metadata"""
143+
144+
meta = MetadataBuilder(
145+
metadata_xml,
146+
config=Config(
147+
v2.ODataV2,
148+
xml_namespaces=namespaces,
149+
))
150+
151+
return meta.build()

‎pyodata/v2/model.py ‎pyodata/model/elements.py

+50-1,082
Large diffs are not rendered by default.

‎pyodata/model/from_etree_callbacks.py

+344
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
""" Reusable implementation of from_etree methods for the most of edm elements """
2+
3+
# pylint: disable=unused-argument, missing-docstring, invalid-name
4+
import logging
5+
6+
from pyodata.config import Config
7+
from pyodata.exceptions import PyODataParserError, PyODataModelError
8+
from pyodata.model.elements import sap_attribute_get_bool, sap_attribute_get_string, StructType, StructTypeProperty, \
9+
NavigationTypeProperty, Identifier, Types, EnumType, EnumMember, EntitySet, EndRole, ReferentialConstraint, \
10+
PrincipalRole, DependentRole, Association, AssociationSetEndRole, AssociationSet, \
11+
ValueHelper, ValueHelperParameter, FunctionImportParameter, \
12+
FunctionImport, metadata_attribute_get, EntityType, ComplexType, Annotation
13+
14+
15+
def modlog():
16+
return logging.getLogger("callbacks")
17+
18+
19+
def struct_type_property_from_etree(entity_type_property_node, config: Config):
20+
return StructTypeProperty(
21+
entity_type_property_node.get('Name'),
22+
Types.parse_type_name(entity_type_property_node.get('Type')),
23+
entity_type_property_node.get('Nullable'),
24+
entity_type_property_node.get('MaxLength'),
25+
entity_type_property_node.get('Precision'),
26+
entity_type_property_node.get('Scale'),
27+
# TODO: create a class SAP attributes
28+
sap_attribute_get_bool(entity_type_property_node, 'unicode', True),
29+
sap_attribute_get_string(entity_type_property_node, 'label'),
30+
sap_attribute_get_bool(entity_type_property_node, 'creatable', True),
31+
sap_attribute_get_bool(entity_type_property_node, 'updatable', True),
32+
sap_attribute_get_bool(entity_type_property_node, 'sortable', True),
33+
sap_attribute_get_bool(entity_type_property_node, 'filterable', True),
34+
sap_attribute_get_string(entity_type_property_node, 'filter-restriction'),
35+
sap_attribute_get_bool(entity_type_property_node, 'required-in-filter', False),
36+
sap_attribute_get_string(entity_type_property_node, 'text'),
37+
sap_attribute_get_bool(entity_type_property_node, 'visible', True),
38+
sap_attribute_get_string(entity_type_property_node, 'display-format'),
39+
sap_attribute_get_string(entity_type_property_node, 'value-list'), )
40+
41+
42+
# pylint: disable=protected-access
43+
def struct_type_from_etree(type_node, config: Config, kwargs):
44+
name = type_node.get('Name')
45+
label = sap_attribute_get_string(type_node, 'label')
46+
is_value_list = sap_attribute_get_bool(type_node, 'value-list', False)
47+
48+
stype = kwargs['type'](name, label, is_value_list)
49+
50+
for proprty in type_node.xpath('edm:Property', namespaces=config.namespaces):
51+
stp = StructTypeProperty.from_etree(proprty, config)
52+
53+
if stp.name in stype._properties:
54+
raise KeyError('{0} already has property {1}'.format(stype, stp.name))
55+
56+
stype._properties[stp.name] = stp
57+
58+
# We have to update the property when
59+
# all properites are loaded because
60+
# there might be links between them.
61+
for ctp in list(stype._properties.values()):
62+
ctp.struct_type = stype
63+
64+
return stype
65+
66+
67+
def navigation_type_property_from_etree(node, config: Config):
68+
return NavigationTypeProperty(
69+
node.get('Name'), node.get('FromRole'), node.get('ToRole'), Identifier.parse(node.get('Relationship')))
70+
71+
72+
def complex_type_from_etree(etree, config: Config):
73+
return StructType.from_etree(etree, config, type=ComplexType)
74+
75+
76+
# pylint: disable=protected-access
77+
def entity_type_from_etree(etree, config: Config):
78+
etype = StructType.from_etree(etree, config, type=EntityType)
79+
80+
for proprty in etree.xpath('edm:Key/edm:PropertyRef', namespaces=config.namespaces):
81+
etype._key.append(etype.proprty(proprty.get('Name')))
82+
83+
for proprty in etree.xpath('edm:NavigationProperty', namespaces=config.namespaces):
84+
navp = NavigationTypeProperty.from_etree(proprty, config)
85+
86+
if navp.name in etype._nav_properties:
87+
raise KeyError('{0} already has navigation property {1}'.format(etype, navp.name))
88+
89+
etype._nav_properties[navp.name] = navp
90+
91+
return etype
92+
93+
94+
# pylint: disable=protected-access, too-many-locals
95+
def enum_type_from_etree(type_node, config: Config, kwargs):
96+
ename = type_node.get('Name')
97+
is_flags = type_node.get('IsFlags')
98+
99+
namespace = kwargs['namespace']
100+
101+
underlying_type = type_node.get('UnderlyingType')
102+
103+
valid_types = {
104+
'Edm.Byte': [0, 2 ** 8 - 1],
105+
'Edm.Int16': [-2 ** 15, 2 ** 15 - 1],
106+
'Edm.Int32': [-2 ** 31, 2 ** 31 - 1],
107+
'Edm.Int64': [-2 ** 63, 2 ** 63 - 1],
108+
'Edm.SByte': [-2 ** 7, 2 ** 7 - 1]
109+
}
110+
111+
if underlying_type not in valid_types:
112+
raise PyODataParserError(
113+
f'Type {underlying_type} is not valid as underlying type for EnumType - must be one of {valid_types}')
114+
115+
mtype = Types.from_name(underlying_type, config)
116+
etype = EnumType(ename, is_flags, mtype, namespace)
117+
118+
members = type_node.xpath('edm:Member', namespaces=config.namespaces)
119+
120+
next_value = 0
121+
for member in members:
122+
name = member.get('Name')
123+
value = member.get('Value')
124+
125+
if value is not None:
126+
next_value = int(value)
127+
128+
vtype = valid_types[underlying_type]
129+
if not vtype[0] < next_value < vtype[1]:
130+
raise PyODataParserError(f'Value {next_value} is out of range for type {underlying_type}')
131+
132+
emember = EnumMember(etype, name, next_value)
133+
etype._member.append(emember)
134+
135+
next_value += 1
136+
137+
return etype
138+
139+
140+
def entity_set_from_etree(entity_set_node, config):
141+
name = entity_set_node.get('Name')
142+
et_info = Types.parse_type_name(entity_set_node.get('EntityType'))
143+
144+
# TODO: create a class SAP attributes
145+
addressable = sap_attribute_get_bool(entity_set_node, 'addressable', True)
146+
creatable = sap_attribute_get_bool(entity_set_node, 'creatable', True)
147+
updatable = sap_attribute_get_bool(entity_set_node, 'updatable', True)
148+
deletable = sap_attribute_get_bool(entity_set_node, 'deletable', True)
149+
searchable = sap_attribute_get_bool(entity_set_node, 'searchable', False)
150+
countable = sap_attribute_get_bool(entity_set_node, 'countable', True)
151+
pageable = sap_attribute_get_bool(entity_set_node, 'pageable', True)
152+
topable = sap_attribute_get_bool(entity_set_node, 'topable', pageable)
153+
req_filter = sap_attribute_get_bool(entity_set_node, 'requires-filter', False)
154+
label = sap_attribute_get_string(entity_set_node, 'label')
155+
156+
return EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable,
157+
topable, req_filter, label)
158+
159+
160+
def end_role_from_etree(end_role_node, config):
161+
entity_type_info = Types.parse_type_name(end_role_node.get('Type'))
162+
multiplicity = end_role_node.get('Multiplicity')
163+
role = end_role_node.get('Role')
164+
165+
return EndRole(entity_type_info, multiplicity, role)
166+
167+
168+
def referential_constraint_from_etree(referential_constraint_node, config: Config):
169+
principal = referential_constraint_node.xpath('edm:Principal', namespaces=config.namespaces)
170+
if len(principal) != 1:
171+
raise RuntimeError('Referential constraint must contain exactly one principal element')
172+
173+
principal_name = principal[0].get('Role')
174+
if principal_name is None:
175+
raise RuntimeError('Principal role name was not specified')
176+
177+
principal_refs = []
178+
for property_ref in principal[0].xpath('edm:PropertyRef', namespaces=config.namespaces):
179+
principal_refs.append(property_ref.get('Name'))
180+
if not principal_refs:
181+
raise RuntimeError('In role {} should be at least one principal property defined'.format(principal_name))
182+
183+
dependent = referential_constraint_node.xpath('edm:Dependent', namespaces=config.namespaces)
184+
if len(dependent) != 1:
185+
raise RuntimeError('Referential constraint must contain exactly one dependent element')
186+
187+
dependent_name = dependent[0].get('Role')
188+
if dependent_name is None:
189+
raise RuntimeError('Dependent role name was not specified')
190+
191+
dependent_refs = []
192+
for property_ref in dependent[0].xpath('edm:PropertyRef', namespaces=config.namespaces):
193+
dependent_refs.append(property_ref.get('Name'))
194+
if len(principal_refs) != len(dependent_refs):
195+
raise RuntimeError('Number of properties should be equal for the principal {} and the dependent {}'
196+
.format(principal_name, dependent_name))
197+
198+
return ReferentialConstraint(
199+
PrincipalRole(principal_name, principal_refs), DependentRole(dependent_name, dependent_refs))
200+
201+
202+
# pylint: disable=protected-access
203+
def association_from_etree(association_node, config: Config):
204+
name = association_node.get('Name')
205+
association = Association(name)
206+
207+
for end in association_node.xpath('edm:End', namespaces=config.namespaces):
208+
end_role = EndRole.from_etree(end, config)
209+
if end_role.entity_type_info is None:
210+
raise RuntimeError('End type is not specified in the association {}'.format(name))
211+
association._end_roles.append(end_role)
212+
213+
if len(association._end_roles) != 2:
214+
raise RuntimeError('Association {} does not have two end roles'.format(name))
215+
216+
refer = association_node.xpath('edm:ReferentialConstraint', namespaces=config.namespaces)
217+
if len(refer) > 1:
218+
raise RuntimeError('In association {} is defined more than one referential constraint'.format(name))
219+
220+
if not refer:
221+
referential_constraint = None
222+
else:
223+
referential_constraint = ReferentialConstraint.from_etree(refer[0], config)
224+
225+
association._referential_constraint = referential_constraint
226+
227+
return association
228+
229+
230+
def association_set_end_role_from_etree(end_node, config):
231+
role = end_node.get('Role')
232+
entity_set = end_node.get('EntitySet')
233+
234+
return AssociationSetEndRole(role, entity_set)
235+
236+
237+
def association_set_from_etree(association_set_node, config: Config):
238+
end_roles = []
239+
name = association_set_node.get('Name')
240+
association = Identifier.parse(association_set_node.get('Association'))
241+
242+
end_roles_list = association_set_node.xpath('edm:End', namespaces=config.namespaces)
243+
if len(end_roles) > 2:
244+
raise PyODataModelError('Association {} cannot have more than 2 end roles'.format(name))
245+
246+
for end_role in end_roles_list:
247+
end_roles.append(AssociationSetEndRole.from_etree(end_role, config))
248+
249+
return AssociationSet(name, association.name, association.namespace, end_roles)
250+
251+
252+
def external_annotation_from_etree(annotations_node, config):
253+
target = annotations_node.get('Target')
254+
255+
if annotations_node.get('Qualifier'):
256+
modlog().warning('Ignoring qualified Annotations of %s', target)
257+
return
258+
259+
for annotation in annotations_node.xpath('edm:Annotation', namespaces=config.annotation_namespace):
260+
annot = Annotation.from_etree(target, config, annotation_node=annotation)
261+
if annot is None:
262+
continue
263+
yield annot
264+
265+
266+
def annotation_from_etree(target, config, kwargs):
267+
annotation_node = kwargs['annotation_node']
268+
term = annotation_node.get('Term')
269+
270+
if term in config.sap_annotation_value_list:
271+
return ValueHelper.from_etree(target, config, annotation_node=annotation_node)
272+
273+
modlog().warning('Unsupported Annotation( %s )', term)
274+
return None
275+
276+
277+
def value_helper_from_etree(target, config, kwargs):
278+
label = None
279+
collection_path = None
280+
search_supported = False
281+
params_node = None
282+
283+
annotation_node = kwargs['annotation_node']
284+
for prop_value in annotation_node.xpath('edm:Record/edm:PropertyValue', namespaces=config.annotation_namespace):
285+
rprop = prop_value.get('Property')
286+
if rprop == 'Label':
287+
label = prop_value.get('String')
288+
elif rprop == 'CollectionPath':
289+
collection_path = prop_value.get('String')
290+
elif rprop == 'SearchSupported':
291+
search_supported = prop_value.get('Bool')
292+
elif rprop == 'Parameters':
293+
params_node = prop_value
294+
295+
value_helper = ValueHelper(target, collection_path, label, search_supported)
296+
297+
if params_node is not None:
298+
for prm in params_node.xpath('edm:Collection/edm:Record', namespaces=config.annotation_namespace):
299+
param = ValueHelperParameter.from_etree(prm, config)
300+
param.value_helper = value_helper
301+
value_helper._parameters.append(param)
302+
303+
return value_helper
304+
305+
306+
def value_helper_parameter_from_etree(value_help_parameter_node, config):
307+
typ = value_help_parameter_node.get('Type')
308+
direction = config.sap_value_helper_directions[typ]
309+
local_prop_name = None
310+
list_prop_name = None
311+
for pval in value_help_parameter_node.xpath('edm:PropertyValue', namespaces=config.annotation_namespace):
312+
pv_name = pval.get('Property')
313+
if pv_name == 'LocalDataProperty':
314+
local_prop_name = pval.get('PropertyPath')
315+
elif pv_name == 'ValueListProperty':
316+
list_prop_name = pval.get('String')
317+
318+
return ValueHelperParameter(direction, local_prop_name, list_prop_name)
319+
320+
321+
# pylint: disable=too-many-locals
322+
def function_import_from_etree(function_import_node, config: Config):
323+
name = function_import_node.get('Name')
324+
entity_set = function_import_node.get('EntitySet')
325+
http_method = metadata_attribute_get(function_import_node, 'HttpMethod')
326+
327+
rt_type = function_import_node.get('ReturnType')
328+
rt_info = None if rt_type is None else Types.parse_type_name(rt_type)
329+
print(name, rt_type, rt_info)
330+
331+
parameters = dict()
332+
for param in function_import_node.xpath('edm:Parameter', namespaces=config.namespaces):
333+
param_name = param.get('Name')
334+
param_type_info = Types.parse_type_name(param.get('Type'))
335+
param_nullable = param.get('Nullable')
336+
param_max_length = param.get('MaxLength')
337+
param_precision = param.get('Precision')
338+
param_scale = param.get('Scale')
339+
param_mode = param.get('Mode')
340+
341+
parameters[param_name] = FunctionImportParameter(param_name, param_type_info, param_nullable,
342+
param_max_length, param_precision, param_scale, param_mode)
343+
344+
return FunctionImport(name, rt_info, entity_set, parameters, http_method)

‎pyodata/model/type_traits.py

+280
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# pylint: disable=missing-docstring
2+
3+
import datetime
4+
import re
5+
6+
from pyodata.exceptions import PyODataException, PyODataModelError
7+
8+
9+
class EdmStructTypeSerializer:
10+
"""Basic implementation of (de)serialization for Edm complex types
11+
12+
All properties existing in related Edm type are taken
13+
into account, others are ignored
14+
15+
TODO: it can happen that inifinite recurision occurs for cases
16+
when property types are referencich each other. We need some research
17+
here to avoid such cases.
18+
"""
19+
20+
@staticmethod
21+
def to_literal(edm_type, value):
22+
23+
# pylint: disable=no-self-use
24+
if not edm_type:
25+
raise PyODataException('Cannot encode value {} without complex type information'.format(value))
26+
27+
result = {}
28+
for type_prop in edm_type.proprties():
29+
if type_prop.name in value:
30+
result[type_prop.name] = type_prop.typ.traits.to_literal(value[type_prop.name])
31+
32+
return result
33+
34+
@staticmethod
35+
def from_json(edm_type, value):
36+
37+
# pylint: disable=no-self-use
38+
if not edm_type:
39+
raise PyODataException('Cannot decode value {} without complex type information'.format(value))
40+
41+
result = {}
42+
for type_prop in edm_type.proprties():
43+
if type_prop.name in value:
44+
result[type_prop.name] = type_prop.typ.traits.from_json(value[type_prop.name])
45+
46+
return result
47+
48+
@staticmethod
49+
def from_literal(edm_type, value):
50+
51+
# pylint: disable=no-self-use
52+
if not edm_type:
53+
raise PyODataException('Cannot decode value {} without complex type information'.format(value))
54+
55+
result = {}
56+
for type_prop in edm_type.proprties():
57+
if type_prop.name in value:
58+
result[type_prop.name] = type_prop.typ.traits.from_literal(value[type_prop.name])
59+
60+
return result
61+
62+
63+
class TypTraits:
64+
"""Encapsulated differences between types"""
65+
66+
def __repr__(self):
67+
return self.__class__.__name__
68+
69+
# pylint: disable=no-self-use
70+
def to_literal(self, value):
71+
return value
72+
73+
# pylint: disable=no-self-use
74+
def from_json(self, value):
75+
return value
76+
77+
def to_json(self, value):
78+
return value
79+
80+
def from_literal(self, value):
81+
return value
82+
83+
84+
class EdmPrefixedTypTraits(TypTraits):
85+
"""Is good for all types where values have form: prefix'value'"""
86+
87+
def __init__(self, prefix):
88+
super(EdmPrefixedTypTraits, self).__init__()
89+
self._prefix = prefix
90+
91+
def to_literal(self, value):
92+
return '{}\'{}\''.format(self._prefix, value)
93+
94+
def from_literal(self, value):
95+
matches = re.match("^{}'(.*)'$".format(self._prefix), value)
96+
if not matches:
97+
raise PyODataModelError(
98+
"Malformed value {0} for primitive Edm type. Expected format is {1}'value'".format(value, self._prefix))
99+
return matches.group(1)
100+
101+
102+
class EdmDateTimeTypTraits(EdmPrefixedTypTraits):
103+
"""Emd.DateTime traits
104+
105+
Represents date and time with values ranging from 12:00:00 midnight,
106+
January 1, 1753 A.D. through 11:59:59 P.M, December 9999 A.D.
107+
108+
Literal form:
109+
datetime'yyyy-mm-ddThh:mm[:ss[.fffffff]]'
110+
NOTE: Spaces are not allowed between datetime and quoted portion.
111+
datetime is case-insensitive
112+
113+
Example 1: datetime'2000-12-12T12:00'
114+
JSON has following format: /Date(1516614510000)/
115+
https://blogs.sap.com/2017/01/05/date-and-time-in-sap-gateway-foundation/
116+
"""
117+
118+
def __init__(self):
119+
super(EdmDateTimeTypTraits, self).__init__('datetime')
120+
121+
def to_literal(self, value):
122+
"""Convert python datetime representation to literal format
123+
124+
None: this could be done also via formatting string:
125+
value.strftime('%Y-%m-%dT%H:%M:%S.%f')
126+
"""
127+
128+
if not isinstance(value, datetime.datetime):
129+
raise PyODataModelError(
130+
'Cannot convert value of type {} to literal. Datetime format is required.'.format(type(value)))
131+
132+
return super(EdmDateTimeTypTraits, self).to_literal(value.replace(tzinfo=None).isoformat())
133+
134+
def to_json(self, value):
135+
if isinstance(value, str):
136+
return value
137+
138+
# Converts datetime into timestamp in milliseconds in UTC timezone as defined in ODATA specification
139+
# https://www.odata.org/documentation/odata-version-2-0/json-format/
140+
return f'/Date({int(value.replace(tzinfo=datetime.timezone.utc).timestamp()) * 1000})/'
141+
142+
def from_json(self, value):
143+
144+
if value is None:
145+
return None
146+
147+
matches = re.match(r"^/Date\((.*)\)/$", value)
148+
if not matches:
149+
raise PyODataModelError(
150+
"Malformed value {0} for primitive Edm type. Expected format is /Date(value)/".format(value))
151+
value = matches.group(1)
152+
153+
try:
154+
# https://stackoverflow.com/questions/36179914/timestamp-out-of-range-for-platform-localtime-gmtime-function
155+
value = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(
156+
milliseconds=int(value))
157+
except ValueError:
158+
raise PyODataModelError('Cannot decode datetime from value {}.'.format(value))
159+
160+
return value
161+
162+
def from_literal(self, value):
163+
164+
if value is None:
165+
return None
166+
167+
value = super(EdmDateTimeTypTraits, self).from_literal(value)
168+
169+
try:
170+
value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f')
171+
except ValueError:
172+
try:
173+
value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S')
174+
except ValueError:
175+
try:
176+
value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M')
177+
except ValueError:
178+
raise PyODataModelError('Cannot decode datetime from value {}.'.format(value))
179+
180+
return value
181+
182+
183+
class EdmStringTypTraits(TypTraits):
184+
"""Edm.String traits"""
185+
186+
# pylint: disable=no-self-use
187+
def to_literal(self, value):
188+
return '\'%s\'' % (value)
189+
190+
# pylint: disable=no-self-use
191+
def from_json(self, value):
192+
return value.strip('\'')
193+
194+
def from_literal(self, value):
195+
return value.strip('\'')
196+
197+
198+
class EdmBooleanTypTraits(TypTraits):
199+
"""Edm.Boolean traits"""
200+
201+
# pylint: disable=no-self-use
202+
def to_literal(self, value):
203+
return 'true' if value else 'false'
204+
205+
# pylint: disable=no-self-use
206+
def from_json(self, value):
207+
return value
208+
209+
def from_literal(self, value):
210+
return value == 'true'
211+
212+
213+
class EdmIntTypTraits(TypTraits):
214+
"""All Edm Integer traits"""
215+
216+
# pylint: disable=no-self-use
217+
def to_literal(self, value):
218+
return '%d' % (value)
219+
220+
# pylint: disable=no-self-use
221+
def from_json(self, value):
222+
return int(value)
223+
224+
def from_literal(self, value):
225+
return int(value)
226+
227+
228+
class EdmLongIntTypTraits(TypTraits):
229+
"""All Edm Integer for big numbers traits"""
230+
231+
# pylint: disable=no-self-use
232+
def to_literal(self, value):
233+
return '%dL' % (value)
234+
235+
# pylint: disable=no-self-use
236+
def from_json(self, value):
237+
if value[-1] == 'L':
238+
return int(value[:-1])
239+
240+
return int(value)
241+
242+
def from_literal(self, value):
243+
return self.from_json(value)
244+
245+
246+
class EdmStructTypTraits(TypTraits):
247+
"""Edm structural types (EntityType, ComplexType) traits"""
248+
249+
def __init__(self, edm_type=None):
250+
super(EdmStructTypTraits, self).__init__()
251+
self._edm_type = edm_type
252+
253+
# pylint: disable=no-self-use
254+
def to_literal(self, value):
255+
return EdmStructTypeSerializer.to_literal(self._edm_type, value)
256+
257+
# pylint: disable=no-self-use
258+
def from_json(self, value):
259+
return EdmStructTypeSerializer.from_json(self._edm_type, value)
260+
261+
def from_literal(self, value):
262+
return EdmStructTypeSerializer.from_json(self._edm_type, value)
263+
264+
265+
class EnumTypTrait(TypTraits):
266+
def __init__(self, enum_type):
267+
self._enum_type = enum_type
268+
269+
def to_literal(self, value):
270+
return f'{value.parent.namespace}.{value}'
271+
272+
def from_json(self, value):
273+
return getattr(self._enum_type, value)
274+
275+
def from_literal(self, value):
276+
# remove namespaces
277+
enum_value = value.split('.')[-1]
278+
# remove enum type
279+
name = enum_value.split("'")[1]
280+
return getattr(self._enum_type, name)

‎pyodata/policies.py

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
This module servers as repository of different kind of errors which can be encounter during parsing and
3+
policies which defines how the parser should response to given error.
4+
"""
5+
6+
import logging
7+
from abc import ABC, abstractmethod
8+
from enum import Enum, auto
9+
10+
11+
class ParserError(Enum):
12+
""" Represents all the different errors the parser is able to deal with."""
13+
PROPERTY = auto()
14+
ANNOTATION = auto()
15+
ASSOCIATION = auto()
16+
17+
ENUM_TYPE = auto()
18+
ENTITY_TYPE = auto()
19+
COMPLEX_TYPE = auto()
20+
21+
22+
class ErrorPolicy(ABC):
23+
""" All policies has to inhere this class"""
24+
@abstractmethod
25+
def resolve(self, ekseption):
26+
""" This method is invoked when an error arise."""
27+
28+
29+
class PolicyFatal(ErrorPolicy):
30+
""" Encounter error should result in parser failing. """
31+
def resolve(self, ekseption):
32+
raise ekseption
33+
34+
35+
class PolicyWarning(ErrorPolicy):
36+
""" Encounter error is logged, but parser continues as nothing has happened """
37+
def __init__(self):
38+
logging.basicConfig(format='%(levelname)s: %(message)s')
39+
self._logger = logging.getLogger()
40+
41+
def resolve(self, ekseption):
42+
self._logger.warning('[%s] %s', ekseption.__class__.__name__, str(ekseption))
43+
44+
45+
class PolicyIgnore(ErrorPolicy):
46+
""" Encounter error is ignored and parser continues as nothing has happened """
47+
def resolve(self, ekseption):
48+
pass

‎pyodata/v2/__init__.py

+296
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
""" This module represents implementation of ODATA V2 """
2+
3+
import itertools
4+
import logging
5+
from typing import List
6+
7+
from pyodata.policies import ParserError
8+
from pyodata.config import ODATAVersion, Config
9+
from pyodata.exceptions import PyODataParserError, PyODataModelError
10+
11+
from pyodata.model.elements import StructTypeProperty, StructType, NavigationTypeProperty, ComplexType, EntityType, \
12+
EnumType, EntitySet, EndRole, ReferentialConstraint, Association, AssociationSetEndRole, AssociationSet, \
13+
ExternalAnnotation, Annotation, ValueHelper, ValueHelperParameter, FunctionImport, Schema, NullType, Typ, \
14+
NullAssociation
15+
16+
from pyodata.model.from_etree_callbacks import struct_type_property_from_etree, struct_type_from_etree, \
17+
navigation_type_property_from_etree, complex_type_from_etree, entity_type_from_etree, enum_type_from_etree, \
18+
entity_set_from_etree, end_role_from_etree, referential_constraint_from_etree, association_from_etree, \
19+
association_set_end_role_from_etree, association_set_from_etree, external_annotation_from_etree, \
20+
annotation_from_etree, value_helper_from_etree, value_helper_parameter_from_etree, function_import_from_etree
21+
22+
23+
def modlog():
24+
""" Logging function for debugging."""
25+
return logging.getLogger("v2")
26+
27+
28+
class ODataV2(ODATAVersion):
29+
""" Definition of OData V2 """
30+
31+
@staticmethod
32+
def from_etree_callbacks():
33+
return {
34+
StructTypeProperty: struct_type_property_from_etree,
35+
StructType: struct_type_from_etree,
36+
NavigationTypeProperty: navigation_type_property_from_etree,
37+
ComplexType: complex_type_from_etree,
38+
EntityType: entity_type_from_etree,
39+
EnumType: enum_type_from_etree,
40+
EntitySet: entity_set_from_etree,
41+
EndRole: end_role_from_etree,
42+
ReferentialConstraint: referential_constraint_from_etree,
43+
Association: association_from_etree,
44+
AssociationSetEndRole: association_set_end_role_from_etree,
45+
AssociationSet: association_set_from_etree,
46+
ExternalAnnotation: external_annotation_from_etree,
47+
Annotation: annotation_from_etree,
48+
ValueHelper: value_helper_from_etree,
49+
ValueHelperParameter: value_helper_parameter_from_etree,
50+
FunctionImport: function_import_from_etree,
51+
Schema: ODataV2.schema_from_etree
52+
}
53+
54+
@staticmethod
55+
def supported_primitive_types() -> List[str]:
56+
return [
57+
'Null',
58+
'Edm.Binary',
59+
'Edm.Boolean',
60+
'Edm.Byte',
61+
'Edm.DateTime',
62+
'Edm.Decimal',
63+
'Edm.Double',
64+
'Edm.Single',
65+
'Edm.Guid',
66+
'Edm.Int16',
67+
'Edm.Int32',
68+
'Edm.Int64',
69+
'Edm.SByte',
70+
'Edm.String',
71+
'Edm.Time',
72+
'Edm.DateTimeOffset',
73+
]
74+
75+
# pylint: disable=too-many-locals,too-many-branches,too-many-statements, protected-access,missing-docstring
76+
@staticmethod
77+
def schema_from_etree(schema_nodes, config: Config):
78+
schema = Schema(config)
79+
80+
# Parse Schema nodes by parts to get over the problem of not-yet known
81+
# entity types referenced by entity sets, function imports and
82+
# annotations.
83+
84+
# First, process EnumType, EntityType and ComplexType nodes. They have almost no dependencies on other elements.
85+
for schema_node in schema_nodes:
86+
namespace = schema_node.get('Namespace')
87+
decl = Schema.Declaration(namespace)
88+
schema._decls[namespace] = decl
89+
90+
for enum_type in schema_node.xpath('edm:EnumType', namespaces=config.namespaces):
91+
try:
92+
etype = EnumType.from_etree(enum_type, config, namespace=namespace)
93+
except (PyODataParserError, AttributeError) as ex:
94+
config.err_policy(ParserError.ENUM_TYPE).resolve(ex)
95+
etype = NullType(enum_type.get('Name'))
96+
97+
decl.add_enum_type(etype)
98+
99+
for complex_type in schema_node.xpath('edm:ComplexType', namespaces=config.namespaces):
100+
try:
101+
ctype = ComplexType.from_etree(complex_type, config)
102+
except (KeyError, AttributeError) as ex:
103+
config.err_policy(ParserError.COMPLEX_TYPE).resolve(ex)
104+
ctype = NullType(complex_type.get('Name'))
105+
106+
decl.add_complex_type(ctype)
107+
108+
for entity_type in schema_node.xpath('edm:EntityType', namespaces=config.namespaces):
109+
try:
110+
etype = EntityType.from_etree(entity_type, config)
111+
except (KeyError, AttributeError) as ex:
112+
config.err_policy(ParserError.ENTITY_TYPE).resolve(ex)
113+
etype = NullType(entity_type.get('Name'))
114+
115+
decl.add_entity_type(etype)
116+
117+
# resolve types of properties
118+
for stype in itertools.chain(schema.entity_types, schema.complex_types):
119+
if isinstance(stype, NullType):
120+
continue
121+
122+
if stype.kind == Typ.Kinds.Complex:
123+
# skip collections (no need to assign any types since type of collection
124+
# items is resolved separately
125+
if stype.is_collection:
126+
continue
127+
128+
for prop in stype.proprties():
129+
try:
130+
prop.typ = schema.get_type(prop.type_info)
131+
except PyODataModelError as ex:
132+
config.err_policy(ParserError.PROPERTY).resolve(ex)
133+
prop.typ = NullType(prop.type_info.name)
134+
135+
# pylint: disable=too-many-nested-blocks
136+
# Then, process Associations nodes because they refer EntityTypes and
137+
# they are referenced by AssociationSets.
138+
for schema_node in schema_nodes:
139+
namespace = schema_node.get('Namespace')
140+
decl = schema._decls[namespace]
141+
142+
for association in schema_node.xpath('edm:Association', namespaces=config.namespaces):
143+
assoc = Association.from_etree(association, config)
144+
try:
145+
for end_role in assoc.end_roles:
146+
try:
147+
# search and assign entity type (it must exist)
148+
if end_role.entity_type_info.namespace is None:
149+
end_role.entity_type_info.namespace = namespace
150+
151+
etype = schema.entity_type(end_role.entity_type_info.name,
152+
end_role.entity_type_info.namespace)
153+
154+
end_role.entity_type = etype
155+
except KeyError:
156+
raise PyODataModelError(
157+
f'EntityType {end_role.entity_type_info.name} does not exist in Schema '
158+
f'Namespace {end_role.entity_type_info.namespace}')
159+
160+
if assoc.referential_constraint is not None:
161+
role_names = [end_role.role for end_role in assoc.end_roles]
162+
principal_role = assoc.referential_constraint.principal
163+
164+
# Check if the role was defined in the current association
165+
if principal_role.name not in role_names:
166+
raise RuntimeError(
167+
'Role {} was not defined in association {}'.format(principal_role.name, assoc.name))
168+
169+
# Check if principal role properties exist
170+
role_name = principal_role.name
171+
entity_type_name = assoc.end_by_role(role_name).entity_type_name
172+
schema.check_role_property_names(principal_role, entity_type_name, namespace)
173+
174+
dependent_role = assoc.referential_constraint.dependent
175+
176+
# Check if the role was defined in the current association
177+
if dependent_role.name not in role_names:
178+
raise RuntimeError(
179+
'Role {} was not defined in association {}'.format(dependent_role.name, assoc.name))
180+
181+
# Check if dependent role properties exist
182+
role_name = dependent_role.name
183+
entity_type_name = assoc.end_by_role(role_name).entity_type_name
184+
schema.check_role_property_names(dependent_role, entity_type_name, namespace)
185+
except (PyODataModelError, RuntimeError) as ex:
186+
config.err_policy(ParserError.ASSOCIATION).resolve(ex)
187+
decl.associations[assoc.name] = NullAssociation(assoc.name)
188+
else:
189+
decl.associations[assoc.name] = assoc
190+
191+
# resolve navigation properties
192+
for stype in schema.entity_types:
193+
# skip null type
194+
if isinstance(stype, NullType):
195+
continue
196+
197+
# skip collections
198+
if stype.is_collection:
199+
continue
200+
201+
for nav_prop in stype.nav_proprties:
202+
try:
203+
assoc = schema.association(nav_prop.association_info.name, nav_prop.association_info.namespace)
204+
nav_prop.association = assoc
205+
except KeyError as ex:
206+
config.err_policy(ParserError.ASSOCIATION).resolve(ex)
207+
nav_prop.association = NullAssociation(nav_prop.association_info.name)
208+
209+
# Then, process EntitySet, FunctionImport and AssociationSet nodes.
210+
for schema_node in schema_nodes:
211+
namespace = schema_node.get('Namespace')
212+
decl = schema._decls[namespace]
213+
214+
for entity_set in schema_node.xpath('edm:EntityContainer/edm:EntitySet', namespaces=config.namespaces):
215+
eset = EntitySet.from_etree(entity_set, config)
216+
eset.entity_type = schema.entity_type(eset.entity_type_info[1], namespace=eset.entity_type_info[0])
217+
decl.entity_sets[eset.name] = eset
218+
219+
for function_import in schema_node.xpath('edm:EntityContainer/edm:FunctionImport',
220+
namespaces=config.namespaces):
221+
efn = FunctionImport.from_etree(function_import, config)
222+
223+
# complete type information for return type and parameters
224+
if efn.return_type_info is not None:
225+
efn.return_type = schema.get_type(efn.return_type_info)
226+
for param in efn.parameters:
227+
param.typ = schema.get_type(param.type_info)
228+
decl.function_imports[efn.name] = efn
229+
230+
for association_set in schema_node.xpath('edm:EntityContainer/edm:AssociationSet',
231+
namespaces=config.namespaces):
232+
assoc_set = AssociationSet.from_etree(association_set, config)
233+
try:
234+
try:
235+
assoc_set.association_type = schema.association(assoc_set.association_type_name,
236+
assoc_set.association_type_namespace)
237+
except KeyError:
238+
raise PyODataModelError(
239+
'Association {} does not exist in namespace {}'
240+
.format(assoc_set.association_type_name, assoc_set.association_type_namespace))
241+
242+
for end in assoc_set.end_roles:
243+
# Check if an entity set exists in the current scheme
244+
# and add a reference to the corresponding entity set
245+
try:
246+
entity_set = schema.entity_set(end.entity_set_name, namespace)
247+
end.entity_set = entity_set
248+
except KeyError:
249+
raise PyODataModelError('EntitySet {} does not exist in Schema Namespace {}'
250+
.format(end.entity_set_name, namespace))
251+
# Check if role is defined in Association
252+
if assoc_set.association_type.end_by_role(end.role) is None:
253+
raise PyODataModelError('Role {} is not defined in association {}'
254+
.format(end.role, assoc_set.association_type_name))
255+
except (PyODataModelError, KeyError) as ex:
256+
config.err_policy(ParserError.ASSOCIATION).resolve(ex)
257+
decl.association_sets[assoc_set.name] = NullAssociation(assoc_set.name)
258+
else:
259+
decl.association_sets[assoc_set.name] = assoc_set
260+
261+
# pylint: disable=too-many-nested-blocks
262+
# Finally, process Annotation nodes when all Scheme nodes are completely processed.
263+
for schema_node in schema_nodes:
264+
for annotation_group in schema_node.xpath('edm:Annotations', namespaces=config.annotation_namespace):
265+
etree = ExternalAnnotation.from_etree(annotation_group, config)
266+
for annotation in etree:
267+
if not annotation.element_namespace != schema.namespaces:
268+
modlog().warning('%s not in the namespaces %s', annotation, ','.join(schema.namespaces))
269+
continue
270+
271+
try:
272+
if annotation.kind == Annotation.Kinds.ValueHelper:
273+
try:
274+
annotation.entity_set = schema.entity_set(
275+
annotation.collection_path, namespace=annotation.element_namespace)
276+
except KeyError:
277+
raise RuntimeError(f'Entity Set {annotation.collection_path} '
278+
f'for {annotation} does not exist')
279+
280+
try:
281+
vh_type = schema.typ(annotation.proprty_entity_type_name,
282+
namespace=annotation.element_namespace)
283+
except KeyError:
284+
raise RuntimeError(f'Target Type {annotation.proprty_entity_type_name} '
285+
f'of {annotation} does not exist')
286+
287+
try:
288+
target_proprty = vh_type.proprty(annotation.proprty_name)
289+
except KeyError:
290+
raise RuntimeError(f'Target Property {annotation.proprty_name} '
291+
f'of {vh_type} as defined in {annotation} does not exist')
292+
annotation.proprty = target_proprty
293+
target_proprty.value_helper = annotation
294+
except (RuntimeError, PyODataModelError) as ex:
295+
config.err_policy(ParserError.ANNOTATION).resolve(ex)
296+
return schema

‎pyodata/v2/service.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616

1717
import requests
1818

19+
from pyodata.model import elements
1920
from pyodata.exceptions import HttpError, PyODataException, ExpressionError
20-
from . import model
2121

2222
LOGGER_NAME = 'pyodata.service'
2323

@@ -736,16 +736,16 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key=
736736

737737
# cache value according to multiplicity
738738
if prop.to_role.multiplicity in \
739-
[model.EndRole.MULTIPLICITY_ONE,
740-
model.EndRole.MULTIPLICITY_ZERO_OR_ONE]:
739+
[elements.EndRole.MULTIPLICITY_ONE,
740+
elements.EndRole.MULTIPLICITY_ZERO_OR_ONE]:
741741

742742
# cache None in case we receive nothing (null) instead of entity data
743743
if proprties[prop.name] is None:
744744
self._cache[prop.name] = None
745745
else:
746746
self._cache[prop.name] = EntityProxy(service, None, prop_etype, proprties[prop.name])
747747

748-
elif prop.to_role.multiplicity == model.EndRole.MULTIPLICITY_ZERO_OR_MORE:
748+
elif prop.to_role.multiplicity == elements.EndRole.MULTIPLICITY_ZERO_OR_MORE:
749749
# default value is empty array
750750
self._cache[prop.name] = []
751751

@@ -815,7 +815,7 @@ def nav(self, nav_property):
815815
raise PyODataException('No association set for role {}'.format(navigation_property.to_role))
816816

817817
roles = navigation_property.association.end_roles
818-
if all((role.multiplicity != model.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)):
818+
if all((role.multiplicity != elements.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)):
819819
return NavEntityProxy(self, nav_property, navigation_entity_set.entity_type, {})
820820

821821
return EntitySetProxy(
@@ -1024,7 +1024,7 @@ def nav(self, nav_property, key):
10241024
'No association set for role {} {}'.format(navigation_property.to_role, association_set.end_roles))
10251025

10261026
roles = navigation_property.association.end_roles
1027-
if all((role.multiplicity != model.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)):
1027+
if all((role.multiplicity != elements.EndRole.MULTIPLICITY_ZERO_OR_MORE for role in roles)):
10281028
return self._get_nav_entity(key, nav_property, navigation_entity_set)
10291029

10301030
return EntitySetProxy(
@@ -1260,7 +1260,7 @@ def function_import_handler(fimport, response):
12601260
response_data = response.json()['d']
12611261

12621262
# 1. if return types is "entity type", return instance of appropriate entity proxy
1263-
if isinstance(fimport.return_type, model.EntityType):
1263+
if isinstance(fimport.return_type, elements.EntityType):
12641264
entity_set = self._service.schema.entity_set(fimport.entity_set_name)
12651265
return EntityProxy(self._service, entity_set, fimport.return_type, response_data)
12661266

‎tests/conftest.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import os
44
import pytest
5-
from pyodata.v2.model import schema_from_xml
5+
from pyodata.model.builder import schema_from_xml
66

77

88
@pytest.fixture

‎tests/test_client.py

+15-19
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,20 @@
11
"""PyOData Client tests"""
22

3+
from unittest.mock import patch
34
import responses
45
import requests
56
import pytest
6-
import pyodata
7-
import pyodata.v2.service
8-
from unittest.mock import patch
9-
from pyodata.exceptions import PyODataException, HttpError
10-
from pyodata.v2.model import ParserError, PolicyWarning, PolicyFatal, PolicyIgnore, Config
117

12-
SERVICE_URL = 'http://example.com'
8+
import pyodata
139

10+
from pyodata.exceptions import PyODataException, HttpError
11+
from pyodata.policies import ParserError, PolicyWarning, PolicyFatal, PolicyIgnore
12+
from pyodata.config import Config
1413

15-
@responses.activate
16-
def test_invalid_odata_version():
17-
"""Check handling of request for invalid OData version implementation"""
14+
from pyodata.v2.service import Service
15+
from pyodata.v2 import ODataV2
1816

19-
with pytest.raises(PyODataException) as e_info:
20-
pyodata.Client(SERVICE_URL, requests, 'INVALID VERSION')
21-
22-
assert str(e_info.value).startswith('No implementation for selected odata version')
17+
SERVICE_URL = 'http://example.com'
2318

2419

2520
@responses.activate
@@ -46,13 +41,13 @@ def test_create_service_application_xml(metadata):
4641

4742
client = pyodata.Client(SERVICE_URL, requests)
4843

49-
assert isinstance(client, pyodata.v2.service.Service)
44+
assert isinstance(client, Service)
5045

5146
# onw more test for '/' terminated url
5247

5348
client = pyodata.Client(SERVICE_URL + '/', requests)
5449

55-
assert isinstance(client, pyodata.v2.service.Service)
50+
assert isinstance(client, Service)
5651

5752

5853
@responses.activate
@@ -68,13 +63,13 @@ def test_create_service_text_xml(metadata):
6863

6964
client = pyodata.Client(SERVICE_URL, requests)
7065

71-
assert isinstance(client, pyodata.v2.service.Service)
66+
assert isinstance(client, Service)
7267

7368
# onw more test for '/' terminated url
7469

7570
client = pyodata.Client(SERVICE_URL + '/', requests)
7671

77-
assert isinstance(client, pyodata.v2.service.Service)
72+
assert isinstance(client, Service)
7873

7974

8075
@responses.activate
@@ -127,6 +122,7 @@ def test_client_custom_configuration(mock_warning, metadata):
127122
}
128123

129124
custom_config = Config(
125+
ODataV2,
130126
xml_namespaces=namespaces,
131127
default_error_policy=PolicyFatal(),
132128
custom_error_policies={
@@ -145,10 +141,10 @@ def test_client_custom_configuration(mock_warning, metadata):
145141
'Passing namespaces directly is deprecated. Use class Config instead',
146142
DeprecationWarning
147143
)
148-
assert isinstance(client, pyodata.v2.service.Service)
144+
assert isinstance(client, Service)
149145
assert client.schema.config.namespaces == namespaces
150146

151147
client = pyodata.Client(SERVICE_URL, requests, config=custom_config)
152148

153-
assert isinstance(client, pyodata.v2.service.Service)
149+
assert isinstance(client, Service)
154150
assert client.schema.config == custom_config

‎tests/test_model.py

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from typing import List
2+
import pytest
3+
4+
from pyodata.config import Config, ODATAVersion
5+
from pyodata.exceptions import PyODataParserError
6+
from pyodata.model.builder import MetadataBuilder
7+
from pyodata.model.elements import Schema, Types
8+
9+
10+
def test_from_etree_mixin(metadata):
11+
"""Test FromEtreeMixin class"""
12+
13+
class EmptyODATA(ODATAVersion):
14+
@staticmethod
15+
def from_etree_callbacks():
16+
return {}
17+
18+
config = Config(EmptyODATA)
19+
builder = MetadataBuilder(metadata, config=config)
20+
21+
with pytest.raises(PyODataParserError) as typ_ex_info:
22+
builder.build()
23+
24+
assert typ_ex_info.value.args[0] == f'{Schema.__name__} is unsupported in {config.odata_version.__name__}'
25+
26+
27+
def test_supported_primitive_types():
28+
"""Test handling of unsupported primitive types class"""
29+
30+
class EmptyODATA(ODATAVersion):
31+
@staticmethod
32+
def supported_primitive_types() -> List[str]:
33+
return [
34+
'Edm.Binary'
35+
]
36+
37+
config = Config(EmptyODATA)
38+
with pytest.raises(KeyError) as typ_ex_info:
39+
Types.from_name('UnsupportedType', config)
40+
41+
assert typ_ex_info.value.args[0] == f'Requested primitive type is not supported in this version of ODATA'
42+
43+
assert Types.from_name('Edm.Binary', config).name == 'Edm.Binary'
44+
45+
46+
def test_odata_version_statelessness():
47+
48+
class EmptyODATA(ODATAVersion):
49+
@staticmethod
50+
def from_etree_callbacks():
51+
return {}
52+
53+
@staticmethod
54+
def supported_primitive_types() -> List[str]:
55+
return []
56+
57+
with pytest.raises(RuntimeError) as typ_ex_info:
58+
EmptyODATA()
59+
60+
assert typ_ex_info.value.args[0] == 'ODATAVersion and its children are intentionally stateless, ' \
61+
'therefore you can not create instance of them'

‎tests/test_model_v2.py

+41-18
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@
44
from datetime import datetime, timezone
55
from unittest.mock import patch
66
import pytest
7-
from pyodata.v2.model import Schema, Typ, StructTypeProperty, Types, EntityType, EdmStructTypeSerializer, \
8-
Association, AssociationSet, EndRole, AssociationSetEndRole, TypeInfo, MetadataBuilder, ParserError, PolicyWarning, \
9-
PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation
10-
from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError
7+
118
from tests.conftest import assert_logging_policy
9+
from pyodata.config import Config
10+
from pyodata.model.builder import MetadataBuilder
11+
from pyodata.model.elements import Typ, Types, EntityType, TypeInfo, NullType, NullAssociation, EndRole, \
12+
AssociationSetEndRole, Schema, StructTypeProperty, AssociationSet, Association
13+
from pyodata.model.type_traits import EdmStructTypeSerializer
14+
from pyodata.policies import ParserError, PolicyWarning, PolicyIgnore, PolicyFatal
15+
from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError
16+
from pyodata.v2 import ODataV2
17+
1218

1319

1420
def test_edmx(schema):
@@ -350,20 +356,22 @@ def test_edmx_complex_type_prop_vh(schema):
350356
def test_traits():
351357
"""Test individual traits"""
352358

359+
config = Config(ODataV2)
360+
353361
# generic
354-
typ = Types.from_name('Edm.Binary')
362+
typ = Types.from_name('Edm.Binary', config)
355363
assert repr(typ.traits) == 'TypTraits'
356364
assert typ.traits.to_literal('bincontent') == 'bincontent'
357365
assert typ.traits.from_literal('some bin content') == 'some bin content'
358366

359367
# string
360-
typ = Types.from_name('Edm.String')
368+
typ = Types.from_name('Edm.String', config)
361369
assert repr(typ.traits) == 'EdmStringTypTraits'
362370
assert typ.traits.to_literal('Foo Foo') == "'Foo Foo'"
363371
assert typ.traits.from_literal("'Alice Bob'") == 'Alice Bob'
364372

365373
# bool
366-
typ = Types.from_name('Edm.Boolean')
374+
typ = Types.from_name('Edm.Boolean', config)
367375
assert repr(typ.traits) == 'EdmBooleanTypTraits'
368376
assert typ.traits.to_literal(True) == 'true'
369377
assert typ.traits.from_literal('true') is True
@@ -376,17 +384,17 @@ def test_traits():
376384
assert typ.traits.from_json(False) is False
377385

378386
# integers
379-
typ = Types.from_name('Edm.Int16')
387+
typ = Types.from_name('Edm.Int16', config)
380388
assert repr(typ.traits) == 'EdmIntTypTraits'
381389
assert typ.traits.to_literal(23) == '23'
382390
assert typ.traits.from_literal('345') == 345
383391

384-
typ = Types.from_name('Edm.Int32')
392+
typ = Types.from_name('Edm.Int32', config)
385393
assert repr(typ.traits) == 'EdmIntTypTraits'
386394
assert typ.traits.to_literal(23) == '23'
387395
assert typ.traits.from_literal('345') == 345
388396

389-
typ = Types.from_name('Edm.Int64')
397+
typ = Types.from_name('Edm.Int64', config)
390398
assert repr(typ.traits) == 'EdmLongIntTypTraits'
391399
assert typ.traits.to_literal(23) == '23L'
392400
assert typ.traits.from_literal('345L') == 345
@@ -399,7 +407,7 @@ def test_traits():
399407
assert typ.traits.from_json('0L') == 0
400408

401409
# GUIDs
402-
typ = Types.from_name('Edm.Guid')
410+
typ = Types.from_name('Edm.Guid', config)
403411
assert repr(typ.traits) == 'EdmPrefixedTypTraits'
404412
assert typ.traits.to_literal('000-0000') == "guid'000-0000'"
405413
assert typ.traits.from_literal("guid'1234-56'") == '1234-56'
@@ -411,7 +419,9 @@ def test_traits():
411419
def test_traits_datetime():
412420
"""Test Edm.DateTime traits"""
413421

414-
typ = Types.from_name('Edm.DateTime')
422+
config = Config(ODataV2)
423+
424+
typ = Types.from_name('Edm.DateTime', config)
415425
assert repr(typ.traits) == 'EdmDateTimeTypTraits'
416426

417427
# 1. direction Python -> OData
@@ -500,10 +510,12 @@ def test_traits_datetime():
500510
def test_traits_collections():
501511
"""Test collection traits"""
502512

503-
typ = Types.from_name('Collection(Edm.Int32)')
513+
config = Config(ODataV2)
514+
515+
typ = Types.from_name('Collection(Edm.Int32)', config)
504516
assert typ.traits.from_json(['23', '34']) == [23, 34]
505517

506-
typ = Types.from_name('Collection(Edm.String)')
518+
typ = Types.from_name('Collection(Edm.String)', config)
507519
assert typ.traits.from_json(['Bob', 'Alice']) == ['Bob', 'Alice']
508520

509521

@@ -545,14 +557,16 @@ def test_type_parsing():
545557
def test_types():
546558
"""Test Types repository"""
547559

560+
config = Config(ODataV2)
561+
548562
# generic
549563
for type_name in ['Edm.Binary', 'Edm.String', 'Edm.Int16', 'Edm.Guid']:
550-
typ = Types.from_name(type_name)
564+
typ = Types.from_name(type_name, config)
551565
assert typ.kind == Typ.Kinds.Primitive
552566
assert not typ.is_collection
553567

554568
# Collection of primitive types
555-
typ = Types.from_name('Collection(Edm.String)')
569+
typ = Types.from_name('Collection(Edm.String)', config)
556570
assert repr(typ) == 'Collection(Typ(Edm.String))'
557571
assert typ.kind is Typ.Kinds.Primitive
558572
assert typ.is_collection
@@ -690,7 +704,7 @@ def test_annot_v_l_missing_e_t(mock_warning, xml_builder_factory):
690704
)
691705

692706

693-
@patch('pyodata.v2.model.PolicyIgnore.resolve')
707+
@patch.object(PolicyIgnore, 'resolve')
694708
@patch('logging.Logger.warning')
695709
def test_annot_v_l_trgt_inv_prop(mock_warning, mock_resolve, xml_builder_factory):
696710
"""Test correct handling of annotations whose target property does not exist"""
@@ -843,6 +857,7 @@ def test_edmx_entity_sets(schema):
843857
def test_config_set_default_error_policy():
844858
""" Test configurability of policies """
845859
config = Config(
860+
ODataV2,
846861
custom_error_policies={
847862
ParserError.ANNOTATION: PolicyWarning()
848863
}
@@ -880,6 +895,7 @@ def test_null_type(xml_builder_factory):
880895
metadata = MetadataBuilder(
881896
xml_builder.serialize(),
882897
config=Config(
898+
ODataV2,
883899
default_error_policy=PolicyIgnore()
884900
))
885901

@@ -927,6 +943,7 @@ def test_faulty_association(xml_builder_factory):
927943
metadata = MetadataBuilder(
928944
xml_builder.serialize(),
929945
config=Config(
946+
ODataV2,
930947
default_error_policy=PolicyIgnore()
931948
))
932949

@@ -953,6 +970,7 @@ def test_faulty_association_set(xml_builder_factory):
953970
metadata = MetadataBuilder(
954971
xml_builder.serialize(),
955972
config=Config(
973+
ODataV2,
956974
default_error_policy=PolicyWarning()
957975
))
958976

@@ -1079,6 +1097,7 @@ def test_unsupported_edmx_n(mock_from_etree, xml_builder_factory):
10791097
MetadataBuilder(
10801098
xml,
10811099
config=Config(
1100+
ODataV2,
10821101
xml_namespaces={'edmx': edmx}
10831102
)
10841103
).build()
@@ -1107,6 +1126,7 @@ def test_unsupported_schema_n(mock_from_etree, xml_builder_factory):
11071126
MetadataBuilder(
11081127
xml,
11091128
config=Config(
1129+
ODataV2,
11101130
xml_namespaces={'edm': edm}
11111131
)
11121132
).build()
@@ -1232,7 +1252,7 @@ def test_enum_value_out_of_range(xml_builder_factory):
12321252

12331253
try:
12341254
MetadataBuilder(xml).build()
1235-
except PyODataParserError as ex:
1255+
except BaseException as ex:
12361256
assert str(ex) == f'Value -130 is out of range for type Edm.Byte'
12371257

12381258

@@ -1286,6 +1306,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac
12861306
'an non existing LocalDataProperty --- of EntityType(MasterEntity)'
12871307

12881308
MetadataBuilder(xml, Config(
1309+
ODataV2,
12891310
default_error_policy=PolicyWarning()
12901311
)).build()
12911312

@@ -1307,6 +1328,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac
13071328
'existing ValueListProperty --- of EntityType(DataEntity)'
13081329

13091330
MetadataBuilder(xml, Config(
1331+
ODataV2,
13101332
default_error_policy=PolicyWarning()
13111333
)).build()
13121334

@@ -1324,6 +1346,7 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac
13241346
mock_warning.reset_mock()
13251347

13261348
MetadataBuilder(xml, Config(
1349+
ODataV2,
13271350
default_error_policy=PolicyWarning()
13281351
)).build()
13291352

‎tests/test_service_v2.py

-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import pytest
77
from unittest.mock import patch
88

9-
import pyodata.v2.model
109
import pyodata.v2.service
1110
from pyodata.exceptions import PyODataException, HttpError, ExpressionError
1211
from pyodata.v2.service import EntityKey, EntityProxy, GetEntitySetFilter

0 commit comments

Comments
 (0)
Please sign in to comment.