Skip to content

Commit 3515a36

Browse files
committed
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 4 additions & 1 deletion
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

Lines changed: 27 additions & 31 deletions
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

Lines changed: 117 additions & 0 deletions
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

Lines changed: 151 additions & 0 deletions
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()

0 commit comments

Comments
 (0)