Skip to content

Commit fd28f5f

Browse files
phanak-sapmnunzioAlbo90
authored
feature: async support (#225)
Add async networking libraries support Co-authored-by: mnunzio <mnunzio90@gmail.com> Co-authored-by: Alberto Moio <moioalberto@gmail.com>
1 parent fd10343 commit fd28f5f

File tree

8 files changed

+408
-36
lines changed

8 files changed

+408
-36
lines changed

.github/workflows/python-package.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414

1515
runs-on: ubuntu-latest
1616
strategy:
17-
fail-fast: true
17+
fail-fast: false
1818
matrix:
1919
python-version: ["3.7", "3.8", "3.9", "3.10.1"]
2020
lxml-version: ["4.1.1", "4.2.6", "4.3.5", "4.4.3", "4.5.2", "4.6.5", "4.7.1", "4.8.0", "4.9.1"]

dev-requirements.txt

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
requests>=2.28.1
22
pytest>=7.1.2
3+
pytest-aiohttp>=1.0.4
34
responses>=0.21.0
5+
httpx>=0.23.0
6+
respx>=0.19.2
47
setuptools>=38.2.4
58
setuptools-scm>=1.15.6
69
pylint==2.8.3

pyodata/client.py

+55-15
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,27 @@
88
from pyodata.exceptions import PyODataException, HttpError
99

1010

11+
async def _async_fetch_metadata(connection, url, logger):
12+
logger.info('Fetching metadata')
13+
14+
async with connection.get(url + '$metadata') as async_response:
15+
resp = pyodata.v2.service.ODataHttpResponse(url=async_response.url,
16+
headers=async_response.headers,
17+
status_code=async_response.status,
18+
content=await async_response.read())
19+
20+
return _common_fetch_metadata(resp, logger)
21+
22+
1123
def _fetch_metadata(connection, url, logger):
1224
# download metadata
1325
logger.info('Fetching metadata')
1426
resp = connection.get(url + '$metadata')
1527

28+
return _common_fetch_metadata(resp, logger)
29+
30+
31+
def _common_fetch_metadata(resp, logger):
1632
logger.debug('Retrieved the response:\n%s\n%s',
1733
'\n'.join((f'H: {key}: {value}' for key, value in resp.headers.items())),
1834
resp.content)
@@ -37,6 +53,25 @@ class Client:
3753

3854
ODATA_VERSION_2 = 2
3955

56+
@staticmethod
57+
async def build_async_client(url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
58+
config: pyodata.v2.model.Config = None, metadata: str = None):
59+
"""Create instance of the OData Client for given URL"""
60+
61+
logger = logging.getLogger('pyodata.client')
62+
63+
if odata_version == Client.ODATA_VERSION_2:
64+
65+
# sanitize url
66+
url = url.rstrip('/') + '/'
67+
68+
if metadata is None:
69+
metadata = await _async_fetch_metadata(connection, url, logger)
70+
else:
71+
logger.info('Using static metadata')
72+
return Client._build_service(logger, url, connection, odata_version, namespaces, config, metadata)
73+
raise PyODataException(f'No implementation for selected odata version {odata_version}')
74+
4075
def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
4176
config: pyodata.v2.model.Config = None, metadata: str = None):
4277
"""Create instance of the OData Client for given URL"""
@@ -53,24 +88,29 @@ def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None
5388
else:
5489
logger.info('Using static metadata')
5590

56-
if config is not None and namespaces is not None:
57-
raise PyODataException('You cannot pass namespaces and config at the same time')
91+
return Client._build_service(logger, url, connection, odata_version, namespaces, config, metadata)
92+
raise PyODataException(f'No implementation for selected odata version {odata_version}')
5893

59-
if config is None:
60-
config = pyodata.v2.model.Config()
94+
@staticmethod
95+
def _build_service(logger, url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
96+
config: pyodata.v2.model.Config = None, metadata: str = None):
6197

62-
if namespaces is not None:
63-
warnings.warn("Passing namespaces directly is deprecated. Use class Config instead", DeprecationWarning)
64-
config.namespaces = namespaces
98+
if config is not None and namespaces is not None:
99+
raise PyODataException('You cannot pass namespaces and config at the same time')
65100

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()
101+
if config is None:
102+
config = pyodata.v2.model.Config()
69103

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, config=config)
104+
if namespaces is not None:
105+
warnings.warn("Passing namespaces directly is deprecated. Use class Config instead", DeprecationWarning)
106+
config.namespaces = namespaces
73107

74-
return service
108+
# create model instance from received metadata
109+
logger.info('Creating OData Schema (version: %d)', odata_version)
110+
schema = pyodata.v2.model.MetadataBuilder(metadata, config=config).build()
75111

76-
raise PyODataException(f'No implementation for selected odata version {odata_version}')
112+
# create service instance based on model we have
113+
logger.info('Creating OData Service (version: %d)', odata_version)
114+
service = pyodata.v2.service.Service(url, schema, connection, config=config)
115+
116+
return service

pyodata/v2/service.py

+64-11
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ def decode(message):
102102
class ODataHttpResponse:
103103
"""Representation of http response"""
104104

105-
def __init__(self, headers, status_code, content=None):
105+
def __init__(self, headers, status_code, content=None, url=None):
106+
self.url = url
106107
self.headers = headers
107108
self.status_code = status_code
108109
self.content = content
@@ -292,14 +293,7 @@ def add_headers(self, value):
292293

293294
self._headers.update(value)
294295

295-
def execute(self):
296-
"""Fetches HTTP response and returns processed result
297-
298-
Sends the query-request to the OData service, returning a client-side Enumerable for
299-
subsequent in-memory operations.
300-
301-
Fetches HTTP response and returns processed result"""
302-
296+
def _build_request(self):
303297
if self._next_url:
304298
url = self._next_url
305299
else:
@@ -315,10 +309,46 @@ def execute(self):
315309
if body:
316310
self._logger.debug(' body: %s', body)
317311

318-
params = urlencode(self.get_query_params())
312+
params = self.get_query_params()
313+
314+
return url, body, headers, params
315+
316+
async def async_execute(self):
317+
"""Fetches HTTP response and returns processed result
318+
319+
Sends the query-request to the OData service, returning a client-side Enumerable for
320+
subsequent in-memory operations.
321+
322+
Fetches HTTP response and returns processed result"""
323+
324+
url, body, headers, params = self._build_request()
325+
async with self._connection.request(self.get_method(),
326+
url,
327+
headers=headers,
328+
params=params,
329+
data=body) as async_response:
330+
response = ODataHttpResponse(url=async_response.url,
331+
headers=async_response.headers,
332+
status_code=async_response.status,
333+
content=await async_response.read())
334+
return self._call_handler(response)
335+
336+
def execute(self):
337+
"""Fetches HTTP response and returns processed result
338+
339+
Sends the query-request to the OData service, returning a client-side Enumerable for
340+
subsequent in-memory operations.
341+
342+
Fetches HTTP response and returns processed result"""
343+
344+
url, body, headers, params = self._build_request()
345+
319346
response = self._connection.request(
320-
self.get_method(), url, headers=headers, params=params, data=body)
347+
self.get_method(), url, headers=headers, params=urlencode(params), data=body)
321348

349+
return self._call_handler(response)
350+
351+
def _call_handler(self, response):
322352
self._logger.debug('Received response')
323353
self._logger.debug(' url: %s', response.url)
324354
self._logger.debug(' headers: %s', response.headers)
@@ -858,6 +888,19 @@ def __getattr__(self, attr):
858888
raise AttributeError('EntityType {0} does not have Property {1}: {2}'
859889
.format(self._entity_type.name, attr, str(ex)))
860890

891+
async def async_getattr(self, attr):
892+
"""Get cached value of attribute or do async call to service to recover attribute value"""
893+
try:
894+
return self._cache[attr]
895+
except KeyError:
896+
try:
897+
value = await self.get_proprty(attr).async_execute()
898+
self._cache[attr] = value
899+
return value
900+
except KeyError as ex:
901+
raise AttributeError('EntityType {0} does not have Property {1}: {2}'
902+
.format(self._entity_type.name, attr, str(ex)))
903+
861904
def nav(self, nav_property):
862905
"""Navigates to given navigation property and returns the EntitySetProxy"""
863906

@@ -1699,6 +1742,16 @@ def http_get(self, path, connection=None):
16991742

17001743
return conn.get(urljoin(self._url, path))
17011744

1745+
async def async_http_get(self, path, connection=None):
1746+
"""HTTP GET response for the passed path in the service"""
1747+
1748+
conn = connection
1749+
if conn is None:
1750+
conn = self._connection
1751+
1752+
async with conn.get(urljoin(self._url, path)) as resp:
1753+
return resp
1754+
17021755
def http_get_odata(self, path, handler, connection=None):
17031756
"""HTTP GET request proxy for the passed path in the service"""
17041757

tests/conftest.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""PyTest Fixtures"""
22
import logging
33
import os
4+
45
import pytest
6+
57
from pyodata.v2.model import schema_from_xml, Types
68

79

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
""" Test the pyodata integration with aiohttp client, based on asyncio
2+
3+
https://docs.aiohttp.org/en/stable/
4+
"""
5+
import aiohttp
6+
from aiohttp import web
7+
import pytest
8+
9+
import pyodata.v2.service
10+
from pyodata import Client
11+
from pyodata.exceptions import PyODataException, HttpError
12+
from pyodata.v2.model import ParserError, PolicyWarning, PolicyFatal, PolicyIgnore, Config
13+
14+
SERVICE_URL = ''
15+
16+
@pytest.mark.asyncio
17+
async def test_invalid_odata_version():
18+
"""Check handling of request for invalid OData version implementation"""
19+
20+
with pytest.raises(PyODataException) as e_info:
21+
async with aiohttp.ClientSession() as client:
22+
await Client.build_async_client(SERVICE_URL, client, 'INVALID VERSION')
23+
24+
assert str(e_info.value).startswith('No implementation for selected odata version')
25+
26+
@pytest.mark.asyncio
27+
async def test_create_client_for_local_metadata(metadata):
28+
"""Check client creation for valid use case with local metadata"""
29+
30+
async with aiohttp.ClientSession() as client:
31+
service_client = await Client.build_async_client(SERVICE_URL, client, metadata=metadata)
32+
33+
assert isinstance(service_client, pyodata.v2.service.Service)
34+
assert service_client.schema.is_valid == True
35+
36+
assert len(service_client.schema.entity_sets) != 0
37+
38+
@pytest.mark.asyncio
39+
def generate_metadata_response(headers=None, body=None, status=200):
40+
41+
async def metadata_response(request):
42+
return web.Response(status=status, headers=headers, body=body)
43+
44+
return metadata_response
45+
46+
47+
@pytest.mark.parametrize("content_type", ['application/xml', 'application/atom+xml', 'text/xml'])
48+
@pytest.mark.asyncio
49+
async def test_create_service_application(aiohttp_client, metadata, content_type):
50+
"""Check client creation for valid MIME types"""
51+
52+
app = web.Application()
53+
app.router.add_get('/$metadata', generate_metadata_response(headers={'content-type': content_type}, body=metadata))
54+
client = await aiohttp_client(app)
55+
56+
service_client = await Client.build_async_client(SERVICE_URL, client)
57+
58+
assert isinstance(service_client, pyodata.v2.service.Service)
59+
60+
# one more test for '/' terminated url
61+
62+
service_client = await Client.build_async_client(SERVICE_URL + '/', client)
63+
64+
assert isinstance(service_client, pyodata.v2.service.Service)
65+
assert service_client.schema.is_valid
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_metadata_not_reachable(aiohttp_client):
70+
"""Check handling of not reachable service metadata"""
71+
72+
app = web.Application()
73+
app.router.add_get('/$metadata', generate_metadata_response(headers={'content-type': 'text/html'}, status=404))
74+
client = await aiohttp_client(app)
75+
76+
with pytest.raises(HttpError) as e_info:
77+
await Client.build_async_client(SERVICE_URL, client)
78+
79+
assert str(e_info.value).startswith('Metadata request failed')
80+
81+
@pytest.mark.asyncio
82+
async def test_metadata_saml_not_authorized(aiohttp_client):
83+
"""Check handling of not SAML / OAuth unauthorized response"""
84+
85+
app = web.Application()
86+
app.router.add_get('/$metadata', generate_metadata_response(headers={'content-type': 'text/html; charset=utf-8'}))
87+
client = await aiohttp_client(app)
88+
89+
with pytest.raises(HttpError) as e_info:
90+
await Client.build_async_client(SERVICE_URL, client)
91+
92+
assert str(e_info.value).startswith('Metadata request did not return XML, MIME type:')
93+
94+
95+
@pytest.mark.asyncio
96+
async def test_client_custom_configuration(aiohttp_client, metadata):
97+
"""Check client creation for custom configuration"""
98+
99+
namespaces = {
100+
'edmx': "customEdmxUrl.com",
101+
'edm': 'customEdmUrl.com'
102+
}
103+
104+
custom_config = Config(
105+
xml_namespaces=namespaces,
106+
default_error_policy=PolicyFatal(),
107+
custom_error_policies={
108+
ParserError.ANNOTATION: PolicyWarning(),
109+
ParserError.ASSOCIATION: PolicyIgnore()
110+
})
111+
112+
app = web.Application()
113+
app.router.add_get('/$metadata',
114+
generate_metadata_response(headers={'content-type': 'application/xml'}, body=metadata))
115+
client = await aiohttp_client(app)
116+
117+
with pytest.raises(PyODataException) as e_info:
118+
await Client.build_async_client(SERVICE_URL, client, config=custom_config, namespaces=namespaces)
119+
120+
assert str(e_info.value) == 'You cannot pass namespaces and config at the same time'
121+
122+
with pytest.warns(DeprecationWarning,match='Passing namespaces directly is deprecated. Use class Config instead'):
123+
service = await Client.build_async_client(SERVICE_URL, client, namespaces=namespaces)
124+
125+
assert isinstance(service, pyodata.v2.service.Service)
126+
assert service.schema.config.namespaces == namespaces
127+
128+
service = await Client.build_async_client(SERVICE_URL, client, config=custom_config)
129+
130+
assert isinstance(service, pyodata.v2.service.Service)
131+
assert service.schema.config == custom_config

0 commit comments

Comments
 (0)