Skip to content
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

Add async networking libraries support #210

Merged
merged 13 commits into from
Mar 17, 2022
Merged
2 changes: 2 additions & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
requests==2.23.0
pytest-asyncio == 0.15.1
aiohttp==3.8.1
Copy link
Contributor

Choose a reason for hiding this comment

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

I have already updated as first commit to the async-feature branch the dev-dependency.txt pytest-aiohttp>=1.0.4.

Wouldn't explicit pytest plugin by better than generic aiohttp library? Just asking what dependency is better, doesn't seem right to have both.

Copy link
Contributor

Choose a reason for hiding this comment

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

Still have same 8 errors running pytest TypeError: sleep() got an unexpected keyword even with the newly added dev-requirements.

Everything is passing in your local environment?

Copy link
Contributor Author

@Albo90 Albo90 Mar 11, 2022

Choose a reason for hiding this comment

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

We don't have errors in our test, I forgot the requirements. We added our dev-requirements.txt. We don't have aiohttp test because we want to stay generic without aiohttp dependency.

Copy link
Contributor

Choose a reason for hiding this comment

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

I must say I am not understanding the last comment at all.

  1. what does "We don't have aiohttp test because we want to stay generic without aiohttp dependency." mean? Especially when you commited aiohttp==3.8.1 to the dev-dependencies already? Do you want that or not?

  2. And should the pytest-aiohttp from me, which already is in the async-feature branch dev-requirements.txt stay or not? Should the async tests be based on pytest-aiohttp plugin or pytest-asyncio +aiohttp ? I expected we will go pytest plugins way.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry if we were not clear, I try to summarize our considerations why they led me not to choose pytest-aiohttp:

  1. rewrite the tests faster and easier in case you want to choose to use another type library that uses async like httpx, this we meant when we said we didn't want to depend on aiohttp for testing
  2. the tests work with Application and you have problems using the get method passing all the complete url. In fact we simply had to replace SERVICE_URL with an empty string.

We have rewritten the tests anyway using pytest-aiohttp but we remain of the idea that it is more useful to use pytest-asyncio + aiohttp.

Copy link
Contributor

@phanak-sap phanak-sap Mar 14, 2022

Choose a reason for hiding this comment

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

Question: is your code in client.py/service.py compatible with httpx python package?
Or does it expect aiohttp.ClientSession() to be provided in the Client.build_async_client only?

If it is more open (best possible outcome, I don't actually know about any other than requests library being used with pyodata, see for example #154 (comment) ), then why not add both integration tests with aiohttpand httpx packages?

Another possibility is that you are ONLY talking about the aiohttp usage in the tests. But then I am still curious about the pyodata package (with this PR) compatibility matrix with async python http networking libraries.

Copy link
Contributor

Choose a reason for hiding this comment

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

We should check, in theory it should work with httpx as well.

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
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""PyTest Fixtures"""
import asyncio
Copy link
Contributor

@phanak-sap phanak-sap Mar 14, 2022

Choose a reason for hiding this comment

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

You are importing direct asyncio package, while in dev dependencies is pytest-asyncio plugin.

Therefore tests are still failing.

Btw, why you prefer the asyncio/aiohttp directly as a dependency and not the pytest plugins, if it is used for testing purposes only anyway? My guess would be that pytest plugin are used by many and doing IMHO the same stuff you are doing by hand - according to readme of both plugins. In this case I think is best to use standard solution of the problem instead of own implementation. I it simple code at the moment, I know, no actual problem merging as is, by me. Just a open discussion.

I have read the comment "we remain of the idea that it is more useful to use pytest-asyncio + aiohttp" - response is there. But on separate topic just asyncio vs pytest-asyncio - it is anyway just about providing basic wrappers around event loop in the pytest fixtures, right?

Copy link
Contributor

Choose a reason for hiding this comment

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

We forgot to update dev-requirements.txt now you shouldn't have any problems with the test anymore. We have removed some useless code.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, with 3bab585 I have passing tests, only with one warning.

pytest_aiohttp\plugin.py:28: DeprecationWarning: The 'asyncio_mode' is 'legacy', switching to 'auto' for the sake of pytest-aiohttp backward compatibility. Please explicitly use 'asyncio_mode=strict' or 'asyncio_mode=auto' in pytest configuration file.
    config.issue_config_time_warning(LEGACY_MODE, stacklevel=2)

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================================== 250 passed, 1 warning in 3.55s

import logging
import os
import pytest
Expand Down Expand Up @@ -139,3 +140,10 @@ def type_date_time():
@pytest.fixture
def type_date_time_offset():
return Types.from_name('Edm.DateTimeOffset')


@pytest.fixture
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.run_until_complete(asyncio.sleep(0.1, loop=loop))
129 changes: 129 additions & 0 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""PyOData Client tests"""
from unittest.mock import patch, AsyncMock

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