Skip to content

Add async networking libraries support #210

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Mar 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
requests==2.23.0
pytest-aiohttp == 1.0.4
pytest>=4.6.0
responses>=0.8.1
setuptools>=38.2.4
Expand Down
70 changes: 55 additions & 15 deletions pyodata/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,27 @@
from pyodata.exceptions import PyODataException, HttpError


async def _async_fetch_metadata(connection, url, logger):
logger.info('Fetching metadata')

async with connection.get(url + '$metadata') as async_response:
resp = pyodata.v2.service.ODataHttpResponse(url=async_response.url,
headers=async_response.headers,
status_code=async_response.status,
content=await async_response.read())

return _common_fetch_metadata(resp, logger)


def _fetch_metadata(connection, url, logger):
# download metadata
logger.info('Fetching metadata')
resp = connection.get(url + '$metadata')

return _common_fetch_metadata(resp, logger)


def _common_fetch_metadata(resp, logger):
logger.debug('Retrieved the response:\n%s\n%s',
'\n'.join((f'H: {key}: {value}' for key, value in resp.headers.items())),
resp.content)
Expand All @@ -37,6 +53,25 @@ class Client:

ODATA_VERSION_2 = 2

@staticmethod
async def build_async_client(url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
config: pyodata.v2.model.Config = None, metadata: str = None):
"""Create instance of the OData Client for given URL"""

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

if odata_version == Client.ODATA_VERSION_2:

# sanitize url
url = url.rstrip('/') + '/'

if metadata is None:
metadata = await _async_fetch_metadata(connection, url, logger)
else:
logger.info('Using static metadata')
return Client._build_service(logger, url, connection, odata_version, namespaces, config, metadata)
raise PyODataException(f'No implementation for selected odata version {odata_version}')

def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
config: pyodata.v2.model.Config = None, metadata: str = None):
"""Create instance of the OData Client for given URL"""
Expand All @@ -53,24 +88,29 @@ def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None
else:
logger.info('Using static metadata')

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

if config is None:
config = pyodata.v2.model.Config()
@staticmethod
def _build_service(logger, url, connection, odata_version=ODATA_VERSION_2, namespaces=None,
config: pyodata.v2.model.Config = None, metadata: str = None):

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

# create model instance from received metadata
logger.info('Creating OData Schema (version: %d)', odata_version)
schema = pyodata.v2.model.MetadataBuilder(metadata, config=config).build()
if config is None:
config = pyodata.v2.model.Config()

# create service instance based on model we have
logger.info('Creating OData Service (version: %d)', odata_version)
service = pyodata.v2.service.Service(url, schema, connection, config=config)
if namespaces is not None:
warnings.warn("Passing namespaces directly is deprecated. Use class Config instead", DeprecationWarning)
config.namespaces = namespaces

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

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

return service
75 changes: 64 additions & 11 deletions pyodata/v2/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ def decode(message):
class ODataHttpResponse:
"""Representation of http response"""

def __init__(self, headers, status_code, content=None):
def __init__(self, headers, status_code, content=None, url=None):
self.url = url
self.headers = headers
self.status_code = status_code
self.content = content
Expand Down Expand Up @@ -292,14 +293,7 @@ def add_headers(self, value):

self._headers.update(value)

def execute(self):
"""Fetches HTTP response and returns processed result

Sends the query-request to the OData service, returning a client-side Enumerable for
subsequent in-memory operations.

Fetches HTTP response and returns processed result"""

def _build_request(self):
if self._next_url:
url = self._next_url
else:
Expand All @@ -315,10 +309,46 @@ def execute(self):
if body:
self._logger.debug(' body: %s', body)

params = urlencode(self.get_query_params())
params = self.get_query_params()

return url, body, headers, params

async def async_execute(self):
"""Fetches HTTP response and returns processed result

Sends the query-request to the OData service, returning a client-side Enumerable for
subsequent in-memory operations.

Fetches HTTP response and returns processed result"""

url, body, headers, params = self._build_request()
async with self._connection.request(self.get_method(),
url,
headers=headers,
params=params,
data=body) as async_response:
response = ODataHttpResponse(url=async_response.url,
headers=async_response.headers,
status_code=async_response.status,
content=await async_response.read())
return self._call_handler(response)

def execute(self):
"""Fetches HTTP response and returns processed result

Sends the query-request to the OData service, returning a client-side Enumerable for
subsequent in-memory operations.

Fetches HTTP response and returns processed result"""

url, body, headers, params = self._build_request()

response = self._connection.request(
self.get_method(), url, headers=headers, params=params, data=body)
self.get_method(), url, headers=headers, params=urlencode(params), data=body)

return self._call_handler(response)

def _call_handler(self, response):
self._logger.debug('Received response')
self._logger.debug(' url: %s', response.url)
self._logger.debug(' headers: %s', response.headers)
Expand Down Expand Up @@ -858,6 +888,19 @@ def __getattr__(self, attr):
raise AttributeError('EntityType {0} does not have Property {1}: {2}'
.format(self._entity_type.name, attr, str(ex)))

async def async_getattr(self, attr):
"""Get cached value of attribute or do async call to service to recover attribute value"""
try:
return self._cache[attr]
except KeyError:
try:
value = await self.get_proprty(attr).async_execute()
self._cache[attr] = value
return value
except KeyError as ex:
raise AttributeError('EntityType {0} does not have Property {1}: {2}'
.format(self._entity_type.name, attr, str(ex)))

def nav(self, nav_property):
"""Navigates to given navigation property and returns the EntitySetProxy"""

Expand Down Expand Up @@ -1686,6 +1729,16 @@ def http_get(self, path, connection=None):

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

async def async_http_get(self, path, connection=None):
"""HTTP GET response for the passed path in the service"""

conn = connection
if conn is None:
conn = self._connection

async with conn.get(urljoin(self._url, path)) as resp:
return resp

def http_get_odata(self, path, handler, connection=None):
"""HTTP GET request proxy for the passed path in the service"""

Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""PyTest Fixtures"""
import logging
import os

import pytest

from pyodata.v2.model import schema_from_xml, Types


Expand Down
130 changes: 130 additions & 0 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""PyOData Client tests"""
from unittest.mock import patch

import aiohttp
import pytest
from aiohttp import web

import pyodata.v2.service
from pyodata import Client
from pyodata.exceptions import PyODataException, HttpError
from pyodata.v2.model import ParserError, PolicyWarning, PolicyFatal, PolicyIgnore, Config

SERVICE_URL = ''


async def test_invalid_odata_version():
"""Check handling of request for invalid OData version implementation"""

with pytest.raises(PyODataException) as e_info:
async with aiohttp.ClientSession() as client:
await Client.build_async_client(SERVICE_URL, client, 'INVALID VERSION')

assert str(e_info.value).startswith('No implementation for selected odata version')


async def test_create_client_for_local_metadata(metadata):
"""Check client creation for valid use case with local metadata"""

async with aiohttp.ClientSession() as client:
service_client = await Client.build_async_client(SERVICE_URL, client, metadata=metadata)

assert isinstance(service_client, pyodata.v2.service.Service)
assert service_client.schema.is_valid == True

assert len(service_client.schema.entity_sets) != 0


def generate_metadata_response(headers=None, body=None, status=200):
async def metadata_repsonse(request):
return web.Response(status=status, headers=headers, body=body)

return metadata_repsonse


@pytest.mark.parametrize("content_type", ['application/xml', 'application/atom+xml', 'text/xml'])
async def test_create_service_application(aiohttp_client, metadata, content_type):
"""Check client creation for valid MIME types"""

app = web.Application()
app.router.add_get('/$metadata', generate_metadata_response(headers={'content-type': content_type}, body=metadata))
client = await aiohttp_client(app)

service_client = await Client.build_async_client(SERVICE_URL, client)

assert isinstance(service_client, pyodata.v2.service.Service)

# one more test for '/' terminated url
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mark at least as TODO to grab more attention in future PR if not done.


service_client = await Client.build_async_client(SERVICE_URL + '/', client)

assert isinstance(service_client, pyodata.v2.service.Service)
assert service_client.schema.is_valid


async def test_metadata_not_reachable(aiohttp_client):
"""Check handling of not reachable service metadata"""

app = web.Application()
app.router.add_get('/$metadata', generate_metadata_response(headers={'content-type': 'text/html'}, status=404))
client = await aiohttp_client(app)

with pytest.raises(HttpError) as e_info:
await Client.build_async_client(SERVICE_URL, client)

assert str(e_info.value).startswith('Metadata request failed')


async def test_metadata_saml_not_authorized(aiohttp_client):
"""Check handling of not SAML / OAuth unauthorized response"""

app = web.Application()
app.router.add_get('/$metadata', generate_metadata_response(headers={'content-type': 'text/html; charset=utf-8'}))
client = await aiohttp_client(app)

with pytest.raises(HttpError) as e_info:
await Client.build_async_client(SERVICE_URL, client)

assert str(e_info.value).startswith('Metadata request did not return XML, MIME type:')


@patch('warnings.warn')
async def test_client_custom_configuration(mock_warning, aiohttp_client, metadata):
"""Check client creation for custom configuration"""

namespaces = {
'edmx': "customEdmxUrl.com",
'edm': 'customEdmUrl.com'
}

custom_config = Config(
xml_namespaces=namespaces,
default_error_policy=PolicyFatal(),
custom_error_policies={
ParserError.ANNOTATION: PolicyWarning(),
ParserError.ASSOCIATION: PolicyIgnore()
})

app = web.Application()
app.router.add_get('/$metadata',
generate_metadata_response(headers={'content-type': 'application/xml'}, body=metadata))
client = await aiohttp_client(app)

with pytest.raises(PyODataException) as e_info:
await Client.build_async_client(SERVICE_URL, client, config=custom_config, namespaces=namespaces)

assert str(e_info.value) == 'You cannot pass namespaces and config at the same time'

service = await Client.build_async_client(SERVICE_URL, client, namespaces=namespaces)

mock_warning.assert_called_with(
'Passing namespaces directly is deprecated. Use class Config instead',
DeprecationWarning
)
assert isinstance(service, pyodata.v2.service.Service)
assert service.schema.config.namespaces == namespaces

service = await Client.build_async_client(SERVICE_URL, client, config=custom_config)

assert isinstance(service, pyodata.v2.service.Service)
assert service.schema.config == custom_config