Skip to content

Commit

Permalink
fix(MetisAPI): ability to run sync API in async runtime
Browse files Browse the repository at this point in the history
  • Loading branch information
knopki committed May 15, 2024
1 parent c5d6e3b commit cbcbdf2
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 6 deletions.
4 changes: 4 additions & 0 deletions metis_client/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,7 @@ class MetisAuthenticationException(MetisError):

class MetisQuotaException(MetisError):
"""This is raised when quota excided."""


class MetisAsyncRuntimeWarning(UserWarning):
"""Use of sync API in async runtime"""
36 changes: 33 additions & 3 deletions metis_client/metis.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"""Metis API synchronous client"""

import asyncio
from functools import partial
from concurrent.futures import ThreadPoolExecutor
from functools import partial, wraps
from typing import Any, Literal, Optional, Sequence, TypeVar, Union, cast
from warnings import warn

from aiohttp.typedefs import StrOrURL
from asgiref.sync import async_to_sync

from metis_client.dtos.datasource import MetisDataSourceDTO

from .compat import Awaitable, Callable, Concatenate, ParamSpec, Unpack
from .exc import MetisAsyncRuntimeWarning
from .metis_async import MetisAPIAsync, MetisAPIKwargs
from .models.base import MetisBase
from .namespaces.v0_calculations import MetisCalculationOnProgressT
Expand Down Expand Up @@ -55,16 +57,44 @@ def to_sync_with_metis_client(
- wrap all with async_to_sync converter
"""

@wraps(func)
async def inner(
self: MetisNamespaceSyncBase, *args: ParamT.args, **kwargs: ParamT.kwargs
) -> ReturnT_co:
"""
Call method with timeout with self, client and other args.
"""
# pylint: disable=protected-access
timeout = self._get_timeout(cast(TimeoutType, kwargs.get("timeout", None)))
# pylint: disable=protected-access
async with self._client_getter() as client:
return await asyncio.wait_for(func(self, client, *args, **kwargs), timeout)

return cast(Any, async_to_sync(inner))
@wraps(func)
def outer(
self: MetisNamespaceSyncBase, *args: ParamT.args, **kwargs: ParamT.kwargs
) -> ReturnT_co:
"""
Execute the async method synchronously in sync and async runtime.
"""
coro = inner(self, *args, **kwargs)
try:
asyncio.get_running_loop() # Triggers RuntimeError if no running event loop

warn(
MetisAsyncRuntimeWarning(
"Using a synchronous API in an asynchronous runtime. "
"Consider switching to MetisAPIAsync."
)
)

# Create a separate thread so we can block before returning
with ThreadPoolExecutor(1) as pool:
return pool.submit(lambda: asyncio.run(coro)).result()
except RuntimeError:
return asyncio.run(coro)

return outer


class MetisCalculationsNamespaceSync(MetisNamespaceSyncBase):
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ requires-python = ">=3.8"
dependencies = [
"aiohttp >= 3.7.4",
"aiohttp-sse-client >= 0.2.1",
"asgiref >= 3.5.2",
"camel-converter >= 3",
"typing-extensions >= 4.2.0; python_version < '3.11'",
"yarl >= 1.6.3",
Expand Down Expand Up @@ -116,6 +115,9 @@ output-format = "colorized"
reports = "no"
score = "no"

[tool.pylint.typecheck]
signature-mutators = "metis_client.metis.to_sync_with_metis_client"

[tool.pylint.similarities]
min-similarity-lines = 8

Expand Down
47 changes: 45 additions & 2 deletions tests/test_metis.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
"Test MetisAPI"

import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from yarl import URL

from metis_client import MetisAPI, MetisNoAuth
from metis_client.exc import (
MetisAsyncRuntimeWarning,
MetisConnectionException,
MetisException,
)


async def create_app() -> web.Application:
"Create web application"
app = web.Application()
return app


@pytest.fixture
async def aiohttp_client(aiohttp_client) -> TestClient:
"Create test client"
app = await create_app()
return await aiohttp_client(TestServer(app))


@pytest.fixture
def base_url(aiohttp_client: TestClient) -> URL:
"Return base url"
return aiohttp_client.make_url("")


@pytest.mark.parametrize(
Expand All @@ -19,8 +46,24 @@
(1, 1, 1),
],
)
async def test_sync_ns_timeout(default_timeout, timeout, result):
async def test_sync_ns_timeout(base_url: URL, default_timeout, timeout, result):
"Test timeout guessing method"
client = MetisAPI("/", auth=MetisNoAuth(), timeout=default_timeout)
client = MetisAPI(base_url, auth=MetisNoAuth(), timeout=default_timeout)
# pylint: disable=protected-access
assert client.v0._get_timeout(timeout) == result


async def test_sync_in_async_runtime(base_url: URL):
"""
Use sync API in an async runtime.
It's impossible to connect to the test fake server
because of runtime block by synchronous function call.
So, we check that:
- RuntimeError is absent (asyncio.run() cannot be called from a running event loop)
- MetisAsyncRuntimeWarning is present about misuse of API
- timeout exception is present
"""
client = MetisAPI(base_url, auth=MetisNoAuth(), timeout=0.0001)
with pytest.warns(MetisAsyncRuntimeWarning):
with pytest.raises((MetisConnectionException, MetisException)): # noqa: B908
client.v0.auth.whoami()

0 comments on commit cbcbdf2

Please sign in to comment.